diff --git a/.env.example b/.env.example index faf6162..9843e8b 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,9 @@ SMTP_STARTTLS=true SMTP_SSL=false # Leer: E-Mail-Adresse des angemeldeten Mitarbeiters SMTP_FROM= +# Fallbacks for confirmed order mail; headquarters MAIL_CC_ADDRESS / MAIL_BCC_ADDRESS take precedence. +ORDER_MAIL_CC= +ORDER_MAIL_BCC= # Bilder für Formulare/Newsletter. Für parallelen PHP-Betrieb ein gemeinsames Verzeichnis einbinden. FORM_IMAGES_DIRECTORY=data/form-images @@ -48,3 +51,89 @@ GROUPWARE_DB_SCHEMA=phoenix_group META_DB_URL= # META_DB_USER= # META_DB_PASSWORD= + +# PZM-Preisvergleich: separate Auftragsquelle (nur SELECT), Tarife aus DB_URL. +COMPARISON_DB_URL= +COMPARISON_DB_USER= +COMPARISON_DB_PASSWORD= + +# Kartenübersicht: Browser-Kacheldienst, keine geheimen API-Schlüssel. +FLEET_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png +FLEET_MAP_ATTRIBUTION=© OpenStreetMap contributors + +# Gemeinsame PHP-Dateiablage: Root ist html/import/upload, kein lokaler PHP-Quellstand. +# local = eingebundener Dateipfad; sftp = Server mit geprüftem SSH-Hostschlüssel. +DOCUMENTS_MODE=local +DOCUMENTS_ROOT= +DOCUMENTS_HOST= +DOCUMENTS_PORT=22 +DOCUMENTS_USER= +DOCUMENTS_PASSWORD= +DOCUMENTS_KEY= +DOCUMENTS_KNOWN_HOSTS= + +# Enable only after retiring the BWV return block in the legacy permanent-job worker. +# Default off during parallel operation; never run both consumers. +BWV_RETOUR_WORKER_ENABLED=false +# Enable only after disabling PHP check_jb_permanent.php continuation for this database. +RECURRING_ORDER_WORKER_ENABLED=false + +# Native Angebotsrückrufe: erst nach Abschalten des PHP-Rückrufblocks aktivieren. +# Die fachlichen Fristen und Modi bleiben in den Parametern der Dispositionsniederlassung. +OFFER_REVOCATION_WORKER_ENABLED=false + +# Status mails: leave disabled while PHP mail processes are active. Before enabling, +# stop the old automailer processes and set AUTOMAILER_ENABLED=0 in the legacy settings. +# Explicitly list the headquarters to migrate. No automatic retry after uncertain SMTP. +ORDER_STATUS_MAIL_WORKER_ENABLED=false +ORDER_STATUS_MAIL_HEADQUARTERS= + +# Enable only after retiring the legacy PHP automatic assignment worker. +AUTO_OFFER_WORKER_ENABLED=false + +# Historische Auftragssnapshots: SSH-Referenz include/glob_defs.inc.php. Nur lesend. +ARCHIVE_DB_URL=jdbc:mariadb://172.16.0.123:3381/phoenix?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false +# Leer/ungesetzt: vorhandene DB_USER/DB_PASSWORD verwenden. +# ARCHIVE_DB_USER= +# ARCHIVE_DB_PASSWORD= + +# Gemeinsame Exportdateien: separat auf html/export/download begrenzen. +EXPORTS_MODE=local +EXPORTS_ROOT= +EXPORTS_HOST= +EXPORTS_PORT=22 +EXPORTS_USER= +EXPORTS_PASSWORD= +EXPORTS_KEY= +EXPORTS_KNOWN_HOSTS= +# GnuPG-Schlüsselbund mit vertrauenswürdigem Empfängerschlüssel aus company.cmp_keyuser. +# Nur nötig, wenn ENCRYPT_EXPORTDATA=1; keine automatische Aktivierung. +EXPORTS_GPG_EXECUTABLE= +EXPORTS_GPG_HOME= + +# Mobile Fotos im gemeinsamen PHP-Verzeichnis documents/photos (nur Lesen). +PHOTOS_MODE=local +PHOTOS_ROOT= +PHOTOS_HOST= +PHOTOS_PORT=22 +PHOTOS_USER= +PHOTOS_PASSWORD= +PHOTOS_KEY= +PHOTOS_KNOWN_HOSTS= + +# Persönliche Links: neue Kunden-/Transporteur-/Mitarbeiterobjekte registrieren; +# nach Mitarbeiterlöschung den zugehörigen zentralen Benutzerschlüssel entfernen. +# Nur gespeicherte NG-Neuanlagen; keine nachträgliche Massenanlage für Bestandsdaten. +META_OBJECT_SYNC_ENABLED=true + +# Zentrale VPA/PDA-Telefone; leer: as_intern aus der Metaobjekt-Datenbank. +APP_DB_URL= +# APP_DB_USER= (Standard: META_DB_USER bzw. DB_USER) +# APP_DB_PASSWORD= (Standard: META_DB_PASSWORD bzw. DB_PASSWORD) +DEVICE_PHONE_SYNC_ENABLED=true + +# Abschlussaufbereitung erst nach Stilllegung des PHP-Prozesses aktivieren. +# Zusätzlich ausdrückliche Freigabe der Niederlassungen, z. B. 7,8. +ORDER_COMPLETION_WORKER_ENABLED=false +ORDER_COMPLETION_PHP_STOPPED=false +ORDER_COMPLETION_HEADQUARTERS= diff --git a/README.md b/README.md index 06a46da..a82435a 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ Die Altanwendung wurde fachlich und technisch analysiert. Die neue Oberfläche b **Die Auftragserfassung läuft jetzt nativ in React und Spring Boot:** Sofortauftrag, Vorbestellung, Dauerauftrag, Barzahlung, direkte Fahrzeugzuweisung und Nacherfassung. Dazu gehören Kostenstellensuche, mehrere Stationen, PLZ-/Gebietstarife oder Festpreise, Preisvorschau, Rechteprüfung und transaktionales Speichern im Bestand. Hinzu kommen Kostensplit, Kurier-Unteraufträge bei Nacherfassungen, Artikelpositionen mit optionalen Lagerbewegungen und eingebettete Kartenauswahl. Voraussetzung ist das bestehende Erfassungsrecht 7; Lagerbewegungen benötigen zusätzlich Recht 14, Unternehmerverrechnung Recht 38. +Kunden- und Transporteurstamm → Portal-Hauptzugang verwaltet Aktivierung, Anmeldename, Passwort, Sperre und 2FA-Rücksetzung der vorhandenen PHP-Portalzugänge. Kontakt- und Abrechnungsdaten bleiben bei einer Zugangssperre erhalten. Zusätzliche Kundenmitarbeiter, eigene Portalrechte und Kostenstellenfreigaben sind unter „Kundenmitarbeiter / Portalrechte“ pflegbar. Fehlt die PHP-Matrixtabelle, bleibt nur die Matrix gesperrt; zur expliziten Einrichtung liegt `backend/sql/portal-costcenter-matrix.sql` bereit. + +Unter Verwaltung → Mitarbeiter → Lagerrechte sind Hauptlagerzugänge, Unterlagereinschränkungen, Lesemodus und das PHP-Artikelverwaltungsflag pflegbar. Lagerbuchungen prüfen diese Zugänge erneut; bestehende fremde Freigaben und geschützte Konten bleiben geschützt. + Adressvorschläge über Google Places ergänzen Kunden-, Kurier- und Kostenstellenadressen. In der Kundenliste lassen sich Kostenstellen einblenden, nach Namen filtern und direkt bearbeiten. Der Google-Schlüssel bleibt im Backend. Der geprüfte Menüumfang und noch fehlende Funktionen stehen in [docs/MENUE-ABGLEICH.md](docs/MENUE-ABGLEICH.md). Freie Artikelpositionen, Alt-Tastenkürzel, Nachhaltigkeitsdaten und Erfassung in freigegebenen Niederlassungen sind ebenfalls implementiert. **Neu aus dem Menüabgleich:** Unter Verwaltung sind Feiertage, CLASSIC-/BWV-Anfahrtszeiten, Gruppen mit Mitgliedschaften und das Impressum angebunden. Die Auswertung berechnet komplette Zeiträume direkt auf dem Bestand, mit Niederlassungen, Vergleichszeiträumen, Gruppierungen, KM/CO₂e und CSV. Der genaue Funktionsumfang und die noch offenen PHP-Funktionen stehen im Menüabgleich. -Die vollständige Ablösung der PHP-Anwendung ist weiterhin offen. Native Auftragsbearbeitung, Fahrzeugzuweisung, Abschluss, Storno/Gegenbuchung, Kunden-/Kurier-/Kostenstellen-/Fahrzeugpflege sowie Abrechnungsübersichten, Rechnungszuordnung, CSV und PDF-Leistungsübersichten sind ergänzt. Vollständige Rechnungserstellung, Buchhaltungsverarbeitung und vorhandene Hintergrunddienste sind weiterhin offen. Foto-Workflow-Sets mit Stationsvorgaben, automatische Kilometer-/PZM- und Fernverkehrskalkulation sowie Auslandsabholung sind angebunden. Die Routenfunktionen benötigen den bestehenden PZM-Dienst (`PZM_URL`) und einen Google-Schlüssel (`GOOGLE_MAPS_API_KEY`); der Fotodienst kommt aus dem Bestandsparameter `URL_CS_PHOTO_WORKFLOW`, die eingebettete Karte aus `URL_GEOCORDS`. Die automatische Adressauflösung gewählter Kartenpunkte verwendet ebenfalls den Google-Schlüssel. Umfang, Betriebsabhängigkeiten und Prüfnachweise stehen in [docs/AUFTRAGSERFASSUNG.md](docs/AUFTRAGSERFASSUNG.md). +Die vollständige Ablösung der PHP-Anwendung ist weiterhin offen. Native Auftragsbearbeitung, Fahrzeugzuweisung, Abschluss, Storno/Gegenbuchung, Kunden-/Kurier-/Kostenstellen-/Fahrzeugpflege sowie Abrechnungsübersichten, Rechnungszuordnung, CSV und PDF-Leistungsübersichten sind ergänzt. Rechnungserstellung, Buchhaltungsläufe und die sechs nativen Automatikdienste sind inzwischen implementiert. Die Betriebsumschaltung der Automatikdienste ist noch ausstehend; der Kategorie-9-Einnahmenexport benötigt weiterhin die Originaldefinition. Der aktuelle Stand und die Prüfgrenzen stehen in [docs/PRUEFSTATUS.md](docs/PRUEFSTATUS.md). Foto-Workflow-Sets mit Stationsvorgaben, automatische Kilometer-/PZM- und Fernverkehrskalkulation sowie Auslandsabholung sind angebunden. Die Routenfunktionen benötigen den bestehenden PZM-Dienst (`PZM_URL`) und einen Google-Schlüssel (`GOOGLE_MAPS_API_KEY`); der Fotodienst kommt aus dem Bestandsparameter `URL_CS_PHOTO_WORKFLOW`, die eingebettete Karte aus `URL_GEOCORDS`. Die automatische Adressauflösung gewählter Kartenpunkte verwendet ebenfalls den Google-Schlüssel. Umfang, Betriebsabhängigkeiten und Prüfnachweise stehen in [docs/AUFTRAGSERFASSUNG.md](docs/AUFTRAGSERFASSUNG.md). Die vollständige Analyse und Abdeckungsmatrix stehen in [docs/ALTSYSTEM-ANALYSE.md](docs/ALTSYSTEM-ANALYSE.md). @@ -137,3 +141,21 @@ docs/ Analyse, Fachregeln, Abdeckung und weitere Abnahme scripts/ Lokaler Start mit vorhandener Konfiguration tests/ Zusätzliche Konfigurations- und Browserprüfungen ``` + + +### Zeitgesteuerte Angebotsrückrufe + +`OFFER_REVOCATION_WORKER_ENABLED=false` hält den nativen Rückruf-/Sperrbereinigungsdienst im PHP-Parallelbetrieb aus. Zur Umschaltung zuerst die Rückruf- und Sperrbereinigungsblöcke in `include/autoranking_main.php` außer Betrieb nehmen, dann den Schalter setzen und NG neu starten. Der Angebotslauf kann separat mit dem nachfolgend beschriebenen nativen Dienst abgelöst werden. Die Fachparameter `AUTORANKING_REVOCATION_ENABLED`, `AUTORANKING_REVOKE_JOB_MODE` und die automatischen/manuellen Minuten-/Sekundenfristen stammen direkt aus der Dispositionsniederlassung. Keine globale Vererbung für diesen PHP-Pfad. Nur transaktionale Schreibziele werden verarbeitet; ungültige Zuordnungen und Fristen bleiben zur Klärung stehen. Die Fristanzeige unter Kurierauswahl → Vermittlung ändern liest diese Einstellungen auch bei ausgeschaltetem NG-Worker. + + +### Automatische Angebote + +`AUTO_OFFER_WORKER_ENABLED=false` hält den nativen Angebotslauf aus. Die lesende Kandidatenvorschau ist bereits unter Kurierauswahl → Vermittlung ändern verfügbar. Vor Aktivierung zuerst den Angebotsblock von `include/autoranking_main.php` beenden; anschließend NG mit `AUTO_OFFER_WORKER_ENABLED=true` neu starten. Rückrufe und Freigabe überholter Kuriersemaphoren müssen weiterhin durch genau einen zuständigen Dienst verarbeitet werden (separater Schalter `OFFER_REVOCATION_WORKER_ENABLED`). Der bestehende Geräteabruf und Auditverbraucher bleiben erforderlich; der vorhandene `keepalive`-Eintrag `autoranking` wird vom aktivierten Angebotslauf fortgeschrieben. + +Der Angebotslauf liest die Fachparameter der Dispositionsniederlassung: Aktivierung, Versuchslimit, Rundenanzahl, Vermittlungsmodus/-tiefe und Fahrzeugregeln. Kundenbezogene Favoriten-/Sperrschalter verwenden den jeweiligen PHP-Parameterfallback. Die Schreibziele `job`, `courier`, `autoranking` und `genericdatacontainer` benötigen InnoDB. Aufträge mit unvollständiger oder mehrdeutiger Konfiguration bleiben zur Klärung stehen. Ausführung ist alle 15 Sekunden nach Abschluss des vorherigen Durchlaufs, mit 40 Aufträgen pro Seite. Es erfolgt keine Schemaumstellung durch den Dienst. + +### Betriebsstatus + +Verwaltung → Betriebsstatus zeigt die sechs nativen Automatikdienste mit Aktivierung, Umfang, letztem abgeschlossenen Lauf und Fehler-/Ergebniszahlen. Die Laufwerte gelten für die gesamte NG-Instanz und beginnen nach einem Neustart neu. Ein Lauf ohne Abschluss seit mehr als drei Minuten wird als überfällig gekennzeichnet; daraus folgt nicht automatisch, dass der Prozess beendet ist. Gemeinsame `keepalive`-Zeitstempel können von PHP oder NG stammen und sind kein Nachweis der Zuständigkeit. + +Auftrags-/Kurierkennzahlen und Geräte-Abschlussmeldungen sind auf die ursprüngliche Niederlassung begrenzt. Erforderlich ist das vorhandene Mitarbeiterverwaltungsrecht (Position 3). Die Ansicht liest ausschließlich; sie startet keine Dienste und versendet keine Nachrichten. Fehlende Datenquellen werden einzeln als nicht verfügbar angezeigt. diff --git a/backend/pom.xml b/backend/pom.xml index d95dc1b..e58520d 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -5,6 +5,10 @@ de.stadtbotevotian-backend0.1.2 Votian NG Stadtbote21 + commons-netcommons-net3.13.0 + com.github.mwiedejsch2.28.5 + com.google.zxingcore3.5.4 + org.apache.pdfboxpdfbox3.0.8 org.jsoupjsoup1.20.1 org.springframework.bootspring-boot-starter-mail org.springframework.bootspring-boot-starter-web diff --git a/backend/sql/portal-costcenter-matrix.sql b/backend/sql/portal-costcenter-matrix.sql new file mode 100644 index 0000000..74f75d8 --- /dev/null +++ b/backend/sql/portal-costcenter-matrix.sql @@ -0,0 +1,9 @@ +-- Optional PHP-compatible schema preparation. Review and apply explicitly to the intended database. +-- No account, permission, or configuration changes; does not enable MASK_EMP_CSC_MATRIX_ENABLED. +-- Deliberately fails when a table already exists: inspect its engine, columns and duplicates first. +CREATE TABLE employeecostcenter ( + emp_id INT NOT NULL, + csc_id INT NOT NULL, + PRIMARY KEY (emp_id, csc_id), + KEY costcenter_employee (csc_id, emp_id) +) ENGINE=InnoDB; diff --git a/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java b/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java new file mode 100644 index 0000000..9228fc9 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AccountTwoFactor.java @@ -0,0 +1,96 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import jakarta.servlet.http.HttpSession; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.time.*; +import java.util.*; +import java.security.SecureRandom; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +@RestController +class AccountTwoFactor { + final DatabaseService db;final BusinessSupport support;final EmployeeAccounts accounts;final Clock clock; + static final String PENDING="votian.twoFactor.pending"; + record Policy(boolean enabled,boolean noSelfDeactivation){} + record State(EmployeeAccounts.State account,Policy policy){} + record Setup(String requestId,String revision,String password){@Override public String toString(){return "Setup[redacted]";}} + record Verify(String requestId,String setupId,String code){@Override public String toString(){return "Verify[redacted]";}} + 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 { + 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]";} + } + @org.springframework.beans.factory.annotation.Autowired AccountTwoFactor(DatabaseService db,BusinessSupport support,EmployeeAccounts accounts){this(db,support,accounts,Clock.systemUTC());} + AccountTwoFactor(DatabaseService db,BusinessSupport support,EmployeeAccounts accounts,Clock clock){this.db=db;this.support=support;this.accounts=accounts;this.clock=clock;} + @GetMapping("/api/account/two-factor") Object own(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId(),true);} + @GetMapping("/api/administration/employees/{employee}/two-factor") Object other(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee,false);} + @PostMapping("/api/account/two-factor/setup") Object setup(Authentication a,HttpSession session,@RequestBody Setup r){return setup(ApiController.user(a),session,r);} + @PostMapping("/api/account/two-factor/activate") Object activate(Authentication a,HttpSession session,@RequestBody Verify r){return activate(ApiController.user(a),session,r);} + @PostMapping("/api/account/two-factor/cancel") Object cancel(Authentication a,HttpSession session,@RequestBody Cancel r){var u=ApiController.user(a);synchronized(session){Object raw=session.getAttribute(PENDING);if(raw instanceof Pending p&&p.user==u.userId()&&r!=null&&p.id.equals(r.setupId()))session.removeAttribute(PENDING);return fields("cancelled",true);}} + @PostMapping("/api/account/two-factor/reset") Object ownReset(Authentication a,@RequestBody Reset r){var u=ApiController.user(a);return reset(u,u.employeeId(),true,r);} + @PostMapping("/api/administration/employees/{employee}/two-factor/reset") Object otherReset(Authentication a,@PathVariable long employee,@RequestBody Reset r){return reset(ApiController.user(a),employee,false,r);} + Policy policy(){ + var rows=db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key IN ('USERTYPE_2FA_ENABLED','USER_2FA_NO_DEACTIVATION') ORDER BY par_key"); + for(String key:List.of("USERTYPE_2FA_ENABLED","USER_2FA_NO_DEACTIVATION"))if(rows.stream().filter(r->text(r,"par_key").equals(key)).count()>1)throw bad("Die Zwei-Faktor-Vorgaben sind nicht eindeutig."); + String enabled=EmployeeAdministration.setting(rows,"USERTYPE_2FA_ENABLED").split(",",-1)[0].strip(),disabled=EmployeeAdministration.setting(rows,"USER_2FA_NO_DEACTIVATION").strip(); + if(!Set.of("","0","1").contains(enabled)||!Set.of("","0","1").contains(disabled))throw bad("Die Zwei-Faktor-Vorgaben sind ungültig.");return new Policy(enabled.equals("1"),disabled.equals("1")); + } + State state(LegacyUser u,long employee,boolean self){return new State(accounts.state(u,employee,self),policy());} + static boolean active(State s){return id(s.account().credentials(),"usr_totp_activated")==1;} + boolean canReset(LegacyUser u,State s,boolean self){ + if(self)return s.policy().enabled()&&!s.policy().noSelfDeactivation(); + var employee=s.account().employee();boolean own=id(employee.person(),"emp_id")==u.employeeId(); + return employee.access().root()&&(own||!employee.protectedAccount())||!employee.protectedAccount()&&EmployeeAdministration.bit(text(employee.access().actor(),"emp_rights"),43); + } + Map view(LegacyUser u,long employee,boolean self){return db.readTransaction(()->{var s=state(u,employee,self);return fields("revision",revision(s),"active",active(s),"canSetup",self&&s.policy().enabled()&&!active(s),"canReset",active(s)&&canReset(u,s,self),"selfDeactivationBlocked",s.policy().noSelfDeactivation(),"self",self,"account",text(s.account().employee().person(),"usr_account"));});} + void authenticateActor(LegacyUser u,String password){if(!accounts.matches(u.userId(),password,true))throw bad("Das aktuelle Passwort ist ungültig.");} + Map setup(LegacyUser u,HttpSession session,Setup r){ + if(r==null)throw bad("Einrichtungsangaben fehlen.");BusinessSupport.token(r.requestId());synchronized(session){ + return db.readTransaction(()->{var s=state(u,u.employeeId(),true);unchanged(s,r.revision());if(!s.policy().enabled()||active(s))throw EmployeeAdministration.denied();authenticateActor(u,r.password()); + Pending pending=session.getAttribute(PENDING) instanceof Pending p?p:null; + if(pending!=null&&pending.user==u.userId()&&pending.setupRequest.equals(r.requestId())&&pending.revision.equals(r.revision())&&clock.instant().isBefore(pending.expires))return setupView(s,pending); + byte[] entropy=new byte[20];new SecureRandom().nextBytes(entropy);String secret=base32(entropy); + var next=new Pending(u.userId(),UUID.randomUUID().toString(),r.revision(),secret,r.requestId(),clock.instant().plusSeconds(600));session.setAttribute(PENDING,next);return setupView(s,next); + }); + } + } + static String base32(byte[] bytes){var out=new StringBuilder();int buffer=0,bits=0;for(byte b:bytes){buffer=(buffer<<8)|(b&255);bits+=8;while(bits>=5){bits-=5;out.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".charAt((buffer>>bits)&31));}}if(bits>0)out.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".charAt((buffer<<(5-bits))&31));return out.toString();} + static String encode(String value){return java.net.URLEncoder.encode(value,java.nio.charset.StandardCharsets.UTF_8).replace("+","%20");} + Map setupView(State s,Pending p){String account=text(s.account().employee().person(),"usr_account");String uri="otpauth://totp/"+encode("Stadtbote:"+account)+"?secret="+p.secret+"&issuer=Stadtbote&algorithm=SHA1&digits=6&period=30";return fields("setupId",p.id,"secret",p.secret,"qrDataUrl",qr(uri),"expiresAt",p.expires.toString(),"account",account);} + static String qr(String uri){ + try{var matrix=new com.google.zxing.qrcode.QRCodeWriter().encode(uri,com.google.zxing.BarcodeFormat.QR_CODE,0,0,Map.of(com.google.zxing.EncodeHintType.MARGIN,4,com.google.zxing.EncodeHintType.ERROR_CORRECTION,com.google.zxing.qrcode.decoder.ErrorCorrectionLevel.M));int n=matrix.getWidth();var path=new StringBuilder();for(int y=0;y";return "data:image/svg+xml;base64,"+Base64.getEncoder().encodeToString(svg.getBytes(java.nio.charset.StandardCharsets.UTF_8));}catch(com.google.zxing.WriterException e){throw new IllegalStateException("QR-Code konnte nicht erzeugt werden.");} + } + Map activate(LegacyUser u,HttpSession session,Verify r){ + if(r==null)throw bad("Bestätigung fehlt.");BusinessSupport.token(r.requestId());BusinessSupport.token(r.setupId());support.transactional("user","genericdatacontainer"); + synchronized(session){ + Object raw=session.getAttribute(PENDING);if(!(raw instanceof Pending p)||p.user!=u.userId()||!p.id.equals(r.setupId())||!clock.instant().isBefore(p.expires))throw bad("Die Einrichtung ist abgelaufen. Bitte erneut beginnen."); + if(p.attempts>=5)throw bad("Zu viele ungültige Codes. Bitte die Einrichtung erneut beginnen."); + return db.employeeWriteTransaction(()->{var s=state(u,u.employeeId(),true);Object intent=List.of("EMPLOYEE_2FA_ACTIVATE",p.id,p.revision);Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous,"reauthenticate",true); + unchanged(s,p.revision);if(active(s)||!s.policy().enabled())throw EmployeeAdministration.denied(); + long seconds=clock.instant().getEpochSecond(); + if(!LegacyTotp.matches(p.secret,r.code(),seconds)){p.attempts++;throw bad("Der Code ist ungültig. Bitte den aktuellen sechsstelligen App-Code eingeben.");} + db.update("UPDATE user SET usr_totp_secret=?,usr_totp_activated=1,usr_totp_sessionkey=? WHERE usr_id=?",p.secret,UUID.randomUUID().toString().replace("-",""),u.userId()); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",u.userId()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngtotp","gdc_obj_id",u.userId(),"gdc_gen_fieldname","login","gdc_content",Long.toString(seconds/30),"gdc_context",revision(p.secret))); + support.auditEmployee(u,u.employeeId(),74,"EMPLOYEE="+u.employeeId()+" | TWO_FACTOR_ACTIVATED",r.requestId());support.receipt(u,r.requestId(),intent,u.employeeId());return fields("id",u.employeeId(),"reauthenticate",true); + }); + } + } + Map reset(LegacyUser u,long employee,boolean self,Reset r){ + if(!self)u.require(3);if(r==null)throw bad("Rücksetzungsangaben fehlen.");BusinessSupport.token(r.requestId());support.transactional("user","genericdatacontainer"); + return db.employeeWriteTransaction(()->{var s=state(u,employee,self);if(!canReset(u,s,self))throw EmployeeAdministration.denied();authenticateActor(u,r.password()); + Object intent=List.of("EMPLOYEE_2FA_RESET",employee,self,Objects.toString(r.revision(),""),Objects.toString(r.account(),""));Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous,"reauthenticate",employee==u.employeeId()); + unchanged(s,r.revision());if(!active(s))throw bad("Die Zwei-Faktor-Anmeldung ist nicht aktiviert.");if(!text(s.account().employee().person(),"usr_account").equals(r.account()))throw bad("Bitte den Anmeldenamen zur Bestätigung eingeben.");long uid=id(s.account().employee().person(),"usr_id"); + if(self){var secret=one(db.queryForList("SELECT usr_totp_secret FROM user WHERE usr_id=?",uid));if(!LegacyTotp.matches(text(secret,"usr_totp_secret"),r.code(),clock.instant().getEpochSecond()))throw bad("Der aktuelle Zwei-Faktor-Code ist ungültig.");} + db.update("UPDATE user SET usr_totp_secret='',usr_totp_activated=0,usr_totp_sessionkey=? WHERE usr_id=?",UUID.randomUUID().toString().replace("-",""),uid); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid); + support.auditEmployee(u,employee,184,"EMPLOYEE="+employee+" | TWO_FACTOR_RESET",r.requestId());support.receipt(u,r.requestId(),intent,employee);return fields("id",employee,"reauthenticate",employee==u.employeeId()); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AccountingExportArchive.java b/backend/src/main/java/de/stadtbote/votian/AccountingExportArchive.java new file mode 100644 index 0000000..e000664 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AccountingExportArchive.java @@ -0,0 +1,91 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Service; +import java.io.*; +import java.time.*; +import java.util.*; +import java.util.zip.Deflater; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.OrderCustomerMailContent.*; + +/** Capture under the export lock, then publish PHP-compatible job2 blobs from a transactional outbox. */ +@Service +final class AccountingExportArchive { + record Payload(long order,byte[] raw,byte[] headquarters,byte[] mail,byte[] customer) {} + record Pending(String request,long order,String digest,int parts) {} + final DatabaseService db;final BusinessSupport support; + AccountingExportArchive(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + static String htmlRow(String name,Object value){return ""+escape(name)+""+escape(value).replace("\n","
").replace("-;-","-;-")+"";} + static String rawValue(Object value){return Objects.toString(value,"").replace('|','/').replace("-;-","- ; -").replace('\0',' ');} + static String pairs(Map values){var out=new StringJoiner("|");values.forEach((k,v)->out.add(k+"="+rawValue(v)));return out.toString();} + static byte[] compress(String value){ + if(value.length()>OrderArchive.MAX_INFLATED)throw bad("Der historische Abrechnungsstand ist zu groß."); + byte[] input=MasterExportFormat.bytes(value);var compressor=new Deflater(6,true);try{compressor.setInput(input);compressor.finish();var out=new ByteArrayOutputStream();byte[] block=new byte[8192];while(!compressor.finished()){int n=compressor.deflate(block);out.write(block,0,n);if(out.size()>65535)throw bad("Der historische Abrechnungsstand überschreitet die bestehende Archivspalte (64 KB). Der Auftrag wurde nicht als exportiert markiert.");}return out.toByteArray();}finally{compressor.end();} + } + List> rows(String sql,List ids){if(ids.isEmpty())return List.of();var out=db.queryForList(sql.replace("{ids}",AccountingExportData.marks(ids.size()))+" LIMIT 100001",ids.toArray());if(out.size()>100000)throw bad("Zu viele Daten für die historische Auftragskopie. Bitte den Export eingrenzen.");return out;} + static Map> unique(List> rows,String key){AccountingExportData.unique(rows,key,"historische Zuordnung");var out=new HashMap>();rows.forEach(r->out.put(id(r,key),r));return out;} + void capture(LegacyUser user,String request,Set selected,LocalDateTime at){ + if(selected.isEmpty())return;var ids=new ArrayList<>(selected); + var existing=rows("SELECT jb_id,OCTET_LENGTH(jb2_history_rawdata) AS rawSize,OCTET_LENGTH(jb2_history_hq) AS hqSize,OCTET_LENGTH(jb2_history_mail) AS mailSize,OCTET_LENGTH(jb2_history_cs) AS csSize FROM job2 WHERE jb_id IN ({ids}) ORDER BY jb_id",ids); + unique(existing,"jb_id");for(var row:existing){if(List.of("rawSize","hqSize","mailSize","csSize").stream().anyMatch(k->id(row,k)==0))throw bad("Auftrag #"+id(row,"jb_id")+": vorhandener Archivstand ist unvollständig.");ids.remove(id(row,"jb_id"));}if(ids.isEmpty())return; + var jobs=rows("SELECT * FROM job WHERE jb_id IN ({ids}) ORDER BY jb_id",ids);if(jobs.size()!=ids.size())throw bad("Ein zu archivierender Auftrag fehlt."); + var stops=rows("SELECT t.*,archive_a.ad_street,archive_a.ad_zipcode,archive_a.ad_city,archive_c.csc_name,archive_cs.cs_eid FROM tour t LEFT JOIN address archive_a ON archive_a.ad_id=t.ad_id LEFT JOIN costcenter archive_c ON archive_c.csc_id=t.csc_id LEFT JOIN customer archive_cs ON archive_cs.cs_id=archive_c.cs_id WHERE t.jb_id IN ({ids}) ORDER BY t.jb_id,t.tr_sort,t.tr_id",ids);var byJob=CustomerExportData.group(stops,"jb_id"); + var costs=rows("SELECT * FROM tourservice WHERE jb_id IN ({ids}) ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id,trs_srv_name",ids);var jobCosts=CustomerExportData.group(costs,"jb_id"); + var prices=CustomerExportData.group(rows("SELECT * FROM jobprice WHERE jb_id IN ({ids}) ORDER BY jb_id,mt_sort,jbprc_price,jbprc_remark",ids),"jb_id"); + var payers=jobs.stream().map(j->id(j,"csc_id_payer")).filter(k->k>0).distinct().sorted().toList(); + var customers=unique(rows("SELECT archive_c.csc_id,archive_c.csc_name,archive_cs.cs_id,archive_cs.cs_eid,archive_cs.cs_jbstatusmail_fields,archive_cs.cs_jbstatusmail_price FROM costcenter archive_c JOIN customer archive_cs ON archive_cs.cs_id=archive_c.cs_id WHERE archive_c.csc_id IN ({ids}) ORDER BY archive_c.csc_id",payers),"csc_id"); + var couriers=unique(rows("SELECT archive_cr.cr_id,archive_cr.cr_eid,archive_cr.cr_sid,archive_u.usr_phone,archive_u.usr_name,archive_u.usr_firstname FROM courier archive_cr LEFT JOIN user archive_u ON archive_u.usr_id=archive_cr.usr_id WHERE archive_cr.cr_id IN ({ids}) ORDER BY archive_cr.cr_id",jobs.stream().map(j->id(j,"cr_id")).filter(k->k>0).distinct().sorted().toList()),"cr_id"); + var vehicles=rows("SELECT archive_cv.cr_id,archive_cv.crvh_sid,archive_cv.crvh_vh_sign FROM couriervehicle archive_cv WHERE archive_cv.cr_id IN ({ids}) ORDER BY archive_cv.cr_id,archive_cv.crvh_sid",jobs.stream().map(j->id(j,"cr_id")).filter(k->k>0).distinct().sorted().toList()); + var employees=unique(rows("SELECT archive_e.emp_id,archive_u.usr_name,archive_u.usr_firstname,archive_u.usr_phone,archive_u.usr_email FROM employee archive_e JOIN user archive_u ON archive_u.usr_id=archive_e.usr_id WHERE archive_e.emp_id IN ({ids}) ORDER BY archive_e.emp_id",jobs.stream().map(j->id(j,"emp_id")).filter(k->k>0).distinct().sorted().toList()),"emp_id"); + var jobMeta=rows("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id IN ({ids}) ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id",ids); + var stopMeta=rows("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id IN ({ids}) ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id",stops.stream().map(s->id(s,"tr_id")).toList()); + var metadata=CustomerExportData.group(jobMeta,"gdc_obj_id");var stopData=CustomerExportData.group(stopMeta,"gdc_obj_id"); + var types=db.queryForList("SELECT mt_type,mt_sort,mt_value FROM metatype WHERE mt_type IN ('vehicletype','internal_remark') ORDER BY mt_type,mt_sort"); + // No passwords or account rows enter the snapshot. Preserve the exact structured source separately from PHP's lossy delimiter format. + var content=new OrderCustomerMailContent(db,user.headquartersId(),""); + for(var original:jobs){long order=id(original,"jb_id");var job=new LinkedHashMap<>(original);job.put("jb_export_time",at);var tour=byJob.getOrDefault(order,List.of());if(tour.isEmpty()||tour.size()>100||tour.stream().map(t->id(t,"tr_sort")).distinct().count()!=tour.size())throw bad("Auftrag #"+order+": Stationen fehlen oder sind nicht eindeutig."); + var payer=customers.getOrDefault(id(job,"csc_id_payer"),Map.of());if(id(job,"csc_id_payer")>0&&payer.isEmpty())throw bad("Auftrag #"+order+": Rechnungskunde fehlt.");var courier=couriers.getOrDefault(id(job,"cr_id"),Map.of());var employee=employees.getOrDefault(id(job,"emp_id"),Map.of()); + var vehicle=vehicles.stream().filter(v->id(v,"cr_id")==id(job,"cr_id")&&text(v,"crvh_sid").equals(text(job,"cr_sid"))).toList();if(vehicle.size()>1)throw bad("Mehrdeutiges Archivfahrzeug.");String plate=vehicle.isEmpty()?"":text(vehicle.getFirst(),"crvh_vh_sign"); + var type=types.stream().filter(t->text(t,"mt_type").equals("vehicletype")&&id(t,"mt_sort")==id(job,"vht_id")).toList();if(type.size()>1)throw bad("Mehrdeutige Fahrzeugart.");String vehicleName=type.isEmpty()?text(job,"vht_id"):text(type.getFirst(),"mt_value"); + + String flags=text(payer,"cs_jbstatusmail_fields");long customer=id(payer,"cs_id");var meta=metadata.getOrDefault(order,List.of());var raw=new LinkedHashMap();job.forEach((k,v)->raw.put("jb_"+k,v));courier.forEach((k,v)->raw.put("cr_"+k,v));employee.forEach((k,v)->raw.put("emp_"+k,v)); + var par=new LinkedHashMap();for(var key:List.of("status","tourname","export_time","reserv","incomplete","longhaul","id_parent","subtotalprice","markup","ordertime"))par.put("job_"+key,job.get("jb_"+key));par.put("job_cr_sid",job.get("cr_sid"));par.put("jobCourierId",id(job,"cr_id"));par.put("cscIdPayer",id(job,"csc_id_payer"));par.put("csIdPayer",customer); + String jobHtml=htmlRow("Nr.",order)+htmlRow("Re.-Ko.-Nr.",text(job,"jb_commission_no"))+htmlRow("Fahrzeug",vehicleName)+htmlRow("Auftragszeit",time(job,"jb_ordertime"))+htmlRow("Annahme",time(job,"jb_taketime"))+htmlRow("Erledigung",time(job,"jb_finishtime")); + var hqJob=new StringBuilder(jobHtml);for(var key:Map.of("jb_orderer","Besteller","jb_tourname","Tourenname","jb_dispoinfo","Dispo-Info","jb_cr_filter","Filter","jb_cr_filter_opt","Opt. Filter","jb_weight","Gewicht","jb_outlay","Auslage").entrySet())hqJob.append(htmlRow(key.getValue(),text(job,key.getKey())));hqJob.append(htmlRow("Abrechnungsexport",at)); + var hqTour=new StringBuilder();var mailTour=new StringBuilder();var csTour=new StringBuilder(); + for(var stop:tour){long position=id(stop,"tr_sort");if(position<1||position>1000)throw bad("Ungültige Stationsnummer im Archiv.");stop.forEach((k,v)->raw.put("tr_"+position+"_"+k,v));var extra=stopData.getOrDefault(id(stop,"tr_id"),List.of());String publicRemark=OrderDeliveryData.publicRemark(text(stop,"tr_remark")); + var common=new StringBuilder(htmlRow("Firma",text(stop,"tr_comp"))+htmlRow("",text(stop,"tr_comp2"))+htmlRow("Person",text(stop,"tr_person"))+htmlRow("Straße + Hausnr.",text(stop,"ad_street")+" "+text(stop,"tr_hsno"))+htmlRow("PLZ + Ort",text(stop,"ad_zipcode")+" "+text(stop,"ad_city"))+htmlRow("EID",text(stop,"cs_eid"))+htmlRow("Tel.",text(stop,"tr_phone"))+htmlRow("Kommissionsnummer",text(stop,"tr_commission_no"))+htmlRow("Erledigungszeit",time(stop,"tr_finishtime"))+htmlRow("Quittungsgeber",text(stop,"tr_signname"))); + var signature=OrderDeliveryData.signature(text(stop,"tr_sign"));common.append(signature==null?htmlRow("Unterschrift",""):OrderMailEvidence.image("Unterschrift",signature)); + // PHP keeps photos outside job2; the native archive reader resolves this row from the station evidence store. + common.append(htmlRow("Abliefernachweis Foto","")); + var allowed=new StringBuilder(common);if(content.object("JOBDETAILS_STATIONS_STATION_NUMBER",customer).equals("1"))allowed.append(htmlRow("Station",position));if(!content.object("JOB_MAIL_STATION_COSTCENTER_DISABLED",customer).equals("1"))allowed.append(htmlRow("Kostenstelle",text(stop,"csc_name")));if(content.object("JOB_MAIL_STATION_ARRIVAL_TIME",customer).equals("1"))allowed.append(htmlRow("Ankunftszeit",OrderDeliveryData.arrival(meta(extra,"arr_tour")))); + String publicTour=allowed.toString();if(bit(flags,0))allowed.append(htmlRow("Bemerkung",publicRemark)); + if(bit(flags,8)&&content.fallback("MASK_JOBDETAILS_STATIONS_POD_SCAN_EVENTS").equals("1"))allowed.append(htmlRow("Bemerkung Kurier",meta(extra,"del_code")+" · "+meta(extra,"del_rem")+"\n"+meta(extra,"tr_hint"))); + if(bit(flags,9)&&content.fallback("MASK_JOBDETAILS_INTERNAL_REMARK_ITEMS").equals("1"))allowed.append(htmlRow("Bemerkung Innendienst",meta(extra,"tr_int_rem"))); + mailTour.append("").append(allowed).append("
");csTour.append("").append(publicTour).append("
");hqTour.append("").append(common).append(htmlRow("Station",position)).append(htmlRow("Kostenstelle",text(stop,"csc_name"))).append(htmlRow("Bemerkung",text(stop,"tr_remark"))).append(htmlRow("Ankunftszeit",OrderDeliveryData.arrival(meta(extra,"arr_tour")))).append(htmlRow("Bemerkung Kurier",meta(extra,"tr_hint"))).append(htmlRow("Bemerkung Innendienst",meta(extra,"tr_int_rem"))).append("
"); + } + var charges=jobCosts.getOrDefault(order,List.of());var costsHtml=new StringBuilder();for(var cost:charges)costsHtml.append(htmlRow("Kostenstelle "+text(cost,"csc_id")+" · "+text(cost,"trs_srv_name"),money(AccountingExportData.number(cost,"trs_price").multiply(java.math.BigDecimal.valueOf(100).subtract(AccountingExportData.number(cost,"trs_discount"))).movePointLeft(2)))); + costsHtml.append(htmlRow("KUNDENPREIS",money(AccountingExportData.number(job,"jb_totalprice")))).append(htmlRow("Ohne Aufschlag",money(AccountingExportData.number(job,"jb_subtotalprice")))).append(htmlRow("Maut / Auslagen",meta(meta,"jb_toll")));String publicCosts=costsHtml.toString();if(bit(flags,2))costsHtml.append(htmlRow("Rechnungstext",text(job,"jb_invtext"))); + String crMail=htmlRow("SID",text(job,"cr_sid"))+(bit(flags,1)?htmlRow("Mobil-Nr.",text(courier,"usr_phone")):"")+(bit(flags,6)?htmlRow("Kennzeichen",plate):"");String crHq=htmlRow("EID",text(courier,"cr_eid"))+htmlRow("SID",text(job,"cr_sid"))+htmlRow("Name",text(courier,"usr_firstname")+" "+text(courier,"usr_name"))+htmlRow("Mobil-Nr.",text(courier,"usr_phone"))+htmlRow("Kennzeichen",plate); + raw.put("tr_numberofstations",tour.size());raw.put("costs_sum",String.format(Locale.GERMANY,"%,.2f",AccountingExportData.number(job,"jb_totalprice")));raw.put("costs_sum_withoutmarkup",String.format(Locale.GERMANY,"%,.2f",AccountingExportData.number(job,"jb_subtotalprice")));raw.put("costs_toll",meta(meta,"jb_toll")); + raw.put("ng_source",Base64.getEncoder().encodeToString(support.encode(fields("job",job,"stations",tour,"costs",charges,"prices",prices.getOrDefault(order,List.of()),"metadata",meta,"stationMetadata",tour.stream().flatMap(t->stopData.getOrDefault(id(t,"tr_id"),List.of()).stream()).toList(),"courier",courier,"employee",employee)).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + String parameters=pairs(par);var payload=new Payload(order,compress(pairs(raw)),compress(String.join("-;-",parameters,hqJob,hqTour,costsHtml+htmlRow("FRACHTVERGÜTUNG",money(AccountingExportData.number(job,"jb_cr_price")))+htmlRow("Frachtratentext",text(job,"jb_invtext_cr")),crHq,htmlRow("Mitarbeiter",text(employee,"usr_firstname")+" "+text(employee,"usr_name")))),compress(String.join("-;-",parameters,jobHtml,mailTour,id(payer,"cs_jbstatusmail_price")==1?costsHtml:"",crMail,"")),compress(String.join("-;-",parameters,jobHtml,csTour,publicCosts,htmlRow("SID",text(job,"cr_sid")),""))); + enqueue(request,payload); + } + } + static String meta(List> rows,String key){var values=rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(key)).toList();if(values.size()>1)throw bad("Mehrdeutiges Archivfeld: "+key);return values.isEmpty()?"":text(values.getFirst(),"gdc_content");} + void enqueue(String request,Payload payload){ + if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_obj_id=? AND gdc_context='PENDING'",payload.order()).isEmpty())throw bad("Die historische Auftragskopie wird noch gespeichert.");String encoded=support.encode(payload);if(encoded.length()>400000)throw bad("Historischer Auftragsstand zu umfangreich.");int count=(encoded.length()+49999)/50000;var pending=new Pending(request,payload.order(),DocumentStore.digest(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8)),count); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngarchive","gdc_obj_id",payload.order(),"gdc_gen_fieldname",request,"gdc_content",support.encode(pending),"gdc_context","PENDING"));for(int n=0;n row){try{var p=support.json.readValue(text(row,"gdc_content"),Pending.class);BusinessSupport.token(p.request());if(p.order()!=id(row,"gdc_obj_id")||p.parts()<1||p.parts()>8)throw bad("Ungültiger Archivauftrag.");var parts=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngarcpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!' ORDER BY gdc_context",p.order(),p.request()+"!_%");if(parts.size()!=p.parts())throw bad("Unvollständiger Archivauftrag.");String encoded=String.join("",parts.stream().map(v->text(v,"gdc_content")).toList());if(encoded.length()>400000||!DocumentStore.digest(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8)).equals(p.digest()))throw bad("Der Archivauftrag wurde verändert.");var value=support.json.readValue(encoded,Payload.class);if(value.order()!=p.order())throw bad("Abweichende Archivkennung.");for(byte[] bytes:List.of(value.raw(),value.headquarters(),value.mail(),value.customer()))OrderArchive.inflate(bytes);return value;}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Ungültiger Archivauftrag.");}} + @org.springframework.scheduling.annotation.Scheduled(initialDelay=60000,fixedDelay=30000) + public void publish(){ + List> candidates;try{candidates=db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 30");}catch(RuntimeException e){return;} + for(var candidate:candidates)try{db.accountingArchiveWriteTransaction(()->{long key=id(candidate,"gdc_id");var rows=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngarchive' AND gdc_context='PENDING'",key);if(rows.isEmpty())return null;var row=one(rows);var p=payload(row);var existing=db.queryForList("SELECT * FROM job2 WHERE jb_id=?",p.order());if(existing.isEmpty())BusinessSupport.insertWithoutKey(db,"job2",fields("jb_id",p.order(),"jb2_history_rawdata",p.raw(),"jb2_history_hq",p.headquarters(),"jb2_history_mail",p.mail(),"jb2_history_cs",p.customer()));else{var old=one(existing);for(var pair:Map.of("jb2_history_rawdata",p.raw(),"jb2_history_hq",p.headquarters(),"jb2_history_mail",p.mail(),"jb2_history_cs",p.customer()).entrySet())if(!(old.get(pair.getKey()) instanceof byte[] bytes)||!Arrays.equals(bytes,pair.getValue()))throw bad("Ein anderer historischer Abrechnungsstand ist bereits vorhanden.");} + db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",key);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngarcpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",p.order(),text(row,"gdc_gen_fieldname")+"!_%");return null;});}catch(RuntimeException ignored){/* Keep exact blobs for retry; never rebuild from changed live orders. */} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java b/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java new file mode 100644 index 0000000..30b530b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AccountingExportData.java @@ -0,0 +1,91 @@ +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.*; +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 { + record Filter(String customer,Long job,Integer payment,List vehicles) {} + record Query(int category,int status,LocalDate from,LocalDate to,Filter filter) {} + static final List PARAMETERS=List.of("EXPORT_CONST_PRODUCT","EXPORT_CONST_PRICE_SPECIAL","EXPORT_CONST_STAFFER","EXPORT_CONST_CONTACT_PERSON","EXPORT_CONST_01","EXPORT_CONST_02","EXPORT_CONST_03","JB_PAYMODE_CASH","JB_TAX_RATE_SIGN","EXPORT_ADD_PAYING_CSCAD_DATA_ADT_1","EXPORT_ADD_PAYING_CSCAD_DATA_ADT_2","EXPORT_ADD_PAYING_CSCAD_DATA_ADT_3","EXPORT_ADD_CS_MASTER_DATA","EXPORT_FILTER_CALCULATOR_NO_BOOKINGNOTES"); + final DatabaseService db; + AccountingExportData(DatabaseService db){this.db=db;} + static Filter checked(Query q){ + if(q==null||q.category()<4||q.category()>6||q.status()<0||q.status()>2||q.to()==null||q.to().getYear()<1900||q.to().getYear()>9998||q.from()!=null&&(q.from().getYear()<1900||q.from().isAfter(q.to())))throw bad("Bitte eine Abrechnungskategorie und einen gültigen Exportzeitraum auswählen."); + var f=q.filter()==null?new Filter("",null,null,List.of()):q.filter();String customer=Objects.toString(f.customer(),"").strip(); + if(customer.length()>20||customer.chars().anyMatch(Character::isISOControl)||f.job()!=null&&(f.job()<1||customer.isEmpty())||f.payment()!=null&&(f.payment()<0||f.payment()>9999))throw bad("Bitte gültige Kunden-, Auftrags- und Zahlartenfilter angeben. Eine einzelne Auftragsnummer benötigt die Kundennummer."); + var vehicles=f.vehicles()==null?List.of():f.vehicles();if(vehicles.size()>100||vehicles.stream().anyMatch(v->v==null||v<0||v>99999))throw bad("Ungültiger Fahrzeugfilter.");return new Filter(customer,f.job(),f.payment(),vehicles.stream().distinct().sorted().toList()); + } + Map parameters(long hq,List evidence){ + var values=db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ("+marks(PARAMETERS.size())+") ORDER BY hq_id,par_key",concat(List.of(hq),PARAMETERS).toArray());evidence.add(values); + var result=new TreeMap();var seen=new HashSet();for(var p:values){String key=text(p,"par_key");if(!seen.add(id(p,"hq_id")+":"+key))throw bad("Mehrdeutige Exportvorgabe: "+key);if(key.equals("EXPORT_FILTER_CALCULATOR_NO_BOOKINGNOTES")&&id(p,"hq_id")!=0)continue;if(id(p,"hq_id")==0&&Set.of("EXPORT_CONST_PRODUCT","EXPORT_CONST_PRICE_SPECIAL","EXPORT_CONST_STAFFER","EXPORT_CONST_CONTACT_PERSON").contains(key))continue;if(!text(p,"par_value").isBlank())result.put(key,text(p,"par_value"));} + result.putIfAbsent("EXPORT_CONST_PRODUCT","K ");result.putIfAbsent("EXPORT_CONST_PRICE_SPECIAL","FP");return result; + } + MasterExportData.Selection select(LegacyUser user,Query q){ + var f=checked(q);var evidence=new ArrayList();var params=parameters(user.headquartersId(),evidence);var master=new MasterExportData(db);var common=master.parameters(user.headquartersId());evidence.add(common);LocalDate today=LocalDate.now(ZoneId.of("Europe/Berlin"));evidence.add(today); + String scope="jb.hq_id_sales=?";var args=new ArrayList();args.add(user.headquartersId());if(f.job()!=null){scope="("+scope+" OR (jb.hq_id=? AND jb.jb_id=?))";args.add(user.headquartersId());args.add(f.job());} + scope+=" AND jb.jb_status=2 AND COALESCE(jb.jb_incomplete,0)=0 AND (jb.jb_export_time IS NULL OR jb.jb_export_time='0000-00-00 00:00:00')"; + if(q.category()!=4){scope+=" AND COALESCE(jb.jb_storno,0) IN (0,"+(q.category()==5?"1":"3")+") AND jb.jb_totalprice"+(q.category()==5?">0":"<0");if(f.payment()==null)scope+=" AND jb.jb_payment IN (0,1,2)";else{scope+=" AND jb.jb_payment=?";args.add(f.payment());}} + int cashStatus=f.customer().isBlank()?q.status():2;if(cashStatus==1)scope+=" AND jb.csc_id_payer=-1";else if(cashStatus==2)scope+=" AND jb.csc_id_payer<>-1"; + String eligible="EXISTS (SELECT 1 FROM tourservice filter_ts JOIN costcenter filter_c ON filter_c.csc_id=filter_ts.csc_id JOIN customer filter_cs ON filter_cs.cs_id=filter_c.cs_id JOIN headquarters filter_hq ON filter_hq.hq_id=filter_cs.hq_id WHERE filter_ts.jb_id=jb.jb_id"; + if(!f.customer().isBlank()){eligible+=" AND filter_cs.cs_eid=?";args.add(f.customer());}eligible+=")"; + scope+=f.customer().isBlank()?" AND ((jb.csc_id_payer=-1 AND EXISTS (SELECT 1 FROM tourservice cash_ts WHERE cash_ts.jb_id=jb.jb_id)) OR (jb.csc_id_payer<>-1 AND "+eligible+"))":" AND "+eligible; + if(q.from()!=null){scope+=" AND jb.jb_finishtime>=?";args.add(q.from().atStartOfDay());}scope+=" AND jb.jb_finishtime0 AND jb.jb_totalprice*jb2.jb_totalprice>0)"; + var jobs=db.queryForList("SELECT jb.*,cr.cr_eid,cr.cr_id_parent,cr.vht_id_inv,cr_hq.hq_mnemonic AS cr_hq_mnemonic,cr_parent.cr_eid AS cr_eid_parent,cr_parent.cr_sid AS cr_sid_parent,cr_parent_hq.hq_mnemonic AS cr_parent_hq_mnemonic,hq.hq_mnemonic,hq.hq_gln FROM job jb JOIN headquarters hq ON hq.hq_id=jb.hq_id LEFT JOIN courier cr ON cr.cr_id=jb.cr_id LEFT JOIN headquarters cr_hq ON cr_hq.hq_id=cr.hq_id LEFT JOIN courier cr_parent ON cr_parent.cr_id=cr.cr_id_parent LEFT JOIN headquarters cr_parent_hq ON cr_parent_hq.hq_id=cr_parent.hq_id LEFT JOIN job jb2 ON jb2.jb_id=jb.jb_id_parent WHERE "+scope+" ORDER BY jb.jb_id LIMIT 2001",args.toArray()); + if(jobs.size()>2000)throw bad("Mehr als 2.000 Exportaufträge. Bitte Zeitraum oder Kunde eingrenzen.");unique(jobs,"jb_id","Auftragszuordnung");evidence.add(jobs);if(jobs.isEmpty())return new MasterExportData.Selection(List.of(),List.of(),evidence,0); + var ids=jobs.stream().map(j->id(j,"jb_id")).toList(); + var priceRows=bounded("SELECT * FROM tourservice WHERE jb_id IN ("+marks(ids.size())+") ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id,trs_srv_name,trs_srvt_name,trs_price,trs_discount",ids,evidence); + var prices=CustomerExportData.group(priceRows,"jb_id"); + var centerIds=new TreeSet();priceRows.forEach(r->centerIds.add(id(r,"csc_id")));jobs.forEach(r->centerIds.add(id(r,"csc_id_payer")));centerIds.removeIf(k->k<=0); + var centers=centerIds.isEmpty()?List.>of():bounded("SELECT csc.csc_id,csc.csc_name,cs.cs_id,cs.cs_eid,cs.csc_id AS csc_id_root,cs.hq_id AS cs_hq_id,cs_hq.hq_mnemonic AS cs_hq_mnemonic,cmp2.cmp_inv2hq,cmp2.cmp_comp AS cmp2_comp,cmp2.cmp_comp2 AS cmp2_comp2,cmp2.cmp_comp3 AS cmp2_comp3,cmp2.cmp_comp4 AS cmp2_comp4,cmp2.cmp_hsno AS ad_mstrdt_hsno,ad_mstrdt.ad_street AS ad_mstrdt_street,ad_mstrdt.ad_zipcode AS ad_mstrdt_zipcode,ad_mstrdt.ad_city AS ad_mstrdt_city FROM costcenter csc JOIN customer cs ON cs.cs_id=csc.cs_id JOIN headquarters cs_hq ON cs_hq.hq_id=cs.hq_id LEFT JOIN company cmp2 ON cmp2.cmp_id=cs.cmp_id LEFT JOIN address ad_mstrdt ON ad_mstrdt.ad_id=cmp2.ad_id WHERE csc.csc_id IN ("+marks(centerIds.size())+") ORDER BY csc.csc_id",new ArrayList<>(centerIds),evidence); + unique(centers,"csc_id","Rechnungskostenstelle");var byCenter=new HashMap>();centers.forEach(c->byCenter.put(id(c,"csc_id"),c)); + var addresses=centerIds.isEmpty()?List.>of():bounded("SELECT cscad.*,ad.ad_street,ad.ad_zipcode,ad.ad_city FROM costcenteraddress cscad LEFT JOIN address ad ON ad.ad_id=cscad.ad_id WHERE cscad.csc_id IN ("+marks(centerIds.size())+") AND cscad.adt_id IN (1,2,3) ORDER BY cscad.csc_id,cscad.adt_id",new ArrayList<>(centerIds),evidence); + var stops=bounded("SELECT t.jb_id,t.tr_id,t.tr_sort,t.tr_commission_no FROM tour t WHERE t.jb_id IN ("+marks(ids.size())+") AND t.tr_sort=1 ORDER BY t.jb_id,t.tr_id",ids,evidence);unique(stops,"jb_id","erste Auftragsstation");var first=new HashMap>();stops.forEach(s->first.put(id(s,"jb_id"),s)); + var metadata=bounded("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id IN ("+marks(ids.size())+") AND gdc_gen_fieldname IN ('jb_toll','jb_third_country','collection_number','ng_payment_term','ng_payment_term_label') ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id",ids,evidence); + var meta=new HashMap>();for(var r:metadata)if(meta.computeIfAbsent(id(r,"gdc_obj_id"),k->new HashMap<>()).put(text(r,"gdc_gen_fieldname"),text(r,"gdc_content"))!=null)throw bad("Mehrdeutiger Auftragsexport-Zusatzwert."); + var legacyTerms=bounded("SELECT jb_id,jbprc_remark FROM jobprice WHERE jb_id IN ("+marks(ids.size())+") AND mt_sort=16 AND TRIM(COALESCE(jbprc_remark,''))<>'' ORDER BY jb_id,jbprc_remark",ids,evidence);unique(legacyTerms,"jb_id","Zahlungsbedingung");var terms=new HashMap();legacyTerms.forEach(t->terms.put(id(t,"jb_id"),text(t,"jbprc_remark"))); + var keys=db.queryForList("SELECT hq_id,par_value FROM parameter WHERE emp_id=0 AND par_key='EXPORT_HQ_KEY' ORDER BY hq_id LIMIT 1001");if(keys.size()>1000)throw bad("Zu viele Niederlassungs-Exportkennungen.");unique(keys,"hq_id","Niederlassungs-Exportkennung");evidence.add(keys);var hqKeys=new HashMap();keys.forEach(k->hqKeys.put(id(k,"hq_id"),text(k,"par_value"))); + long truck=7;for(String item:common.get("MASK_VEHICLE_TYPE_MAPPING").split("\\|")){var p=item.split("=",-1);if(p.length==2&&p[0].equals("truck_01")&&p[1].matches("[0-9]{1,9}")){var types=db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",Long.parseLong(p[1]));evidence.add(types);if(types.size()==1)truck=Long.parseLong(p[1]);}} + var raw=new ArrayList>();var rows=new ArrayList>(); + for(var job:jobs){long jobId=id(job,"jb_id"),payer=id(job,"csc_id_payer");boolean cash=payer==-1;int status=f.customer().isBlank()?q.status():2;if(cash&&status==2||!cash&&status==1)continue; + var groups=prices.getOrDefault(jobId,List.of()).stream().map(p->id(p,"csc_id")).distinct().sorted().toList(); + for(long centerId:groups){var center=byCenter.get(centerId);if(!cash&&(center==null||!f.customer().isBlank()&&!text(center,"cs_eid").equals(f.customer())))continue; + var v=new LinkedHashMap(job);v.put("object_id",jobId);v.put("export_csc_id",centerId);v.put("tr_commission_no",text(first.getOrDefault(jobId,Map.of()),"tr_commission_no"));var payerData=byCenter.getOrDefault(payer,Map.of()); + v.put("cmp_inv2hq",id(payerData,"cmp_inv2hq"));v.put("cmp_single_invoice",id(payerData,"cmp_inv2hq"));v.put("price",job.get("jb_totalprice"));v.put("cash",payer);v.put("tax_rate",job.get("jb_sales_tax_rate"));v.put("tax_rate_sign",job.get("jb_sales_tax_rate_sign"));v.put("gross_price",number(job,"jb_totalprice").multiply(new BigDecimal("1.19"))); + for(String key:List.of("csc_name","csc_id_root","cs_hq_mnemonic"))v.put(key,cash?"":center.get(key));v.put("cs_eid1",cash?"":text(center,"cs_eid"));v.put("cs_eid2",v.get("cs_eid1")); + if(!cash)for(int role=1;role<=3;role++)if(params.getOrDefault("EXPORT_ADD_PAYING_CSCAD_DATA_ADT_"+role,"").equals("1")){int r=role;var matches=addresses.stream().filter(a->id(a,"csc_id")==payer&&id(a,"adt_id")==r).toList();if(matches.size()>1)throw bad("Mehrdeutige Zahleradresse im Export.");var a=matches.isEmpty()?Map.of():matches.getFirst();for(String key:List.of("comp","comp2","comp3","comp4","gln"))v.put("cscad"+role+"_"+key,text(a,"cscad_"+key));for(String key:List.of("street","zipcode","city"))v.put("ad_cscad"+role+"_"+key,text(a,"ad_"+key));v.put("ad_cscad"+role+"_hsno",text(a,"cscad_hsno"));} + if(!cash&¶ms.getOrDefault("EXPORT_ADD_CS_MASTER_DATA","").equals("1"))for(String key:List.of("cmp2_comp","cmp2_comp2","cmp2_comp3","cmp2_comp4","ad_mstrdt_street","ad_mstrdt_hsno","ad_mstrdt_zipcode","ad_mstrdt_city"))v.put(key,payerData.get(key)); + raw.add(new LinkedHashMap<>(v));var m=meta.getOrDefault(jobId,Map.of());v.put("jb_toll",m.getOrDefault("jb_toll",""));v.put("jb_third_country",m.getOrDefault("jb_third_country",""));v.put("jb_payment_term",m.getOrDefault("ng_payment_term_label",m.getOrDefault("ng_payment_term",terms.getOrDefault(jobId,"")))); + String key=hqKeys.getOrDefault(id(job,"hq_id"),"");v.put("cmp_no",key.isBlank()?common.get("EXPORT_HQ_KEY"):key);transform(v,user.headquartersId(),q.category(),params,common,truck); + if(Objects.equals(f.payment(),4)&&!m.getOrDefault("collection_number","").isBlank())v.put("jb_id",m.get("collection_number"));rows.add(v); + } + } + if(rows.size()>10000)throw bad("Mehr als 10.000 Exportzeilen. Bitte die Auswahl eingrenzen.");return new MasterExportData.Selection(List.copyOf(raw),List.copyOf(rows),List.copyOf(evidence),0); + } + static void transform(Map v,long hq,int category,Map p,Map common,long truck){ + for(var t:Map.of("order","jb_ordertime","finish","jb_finishtime").entrySet()){v.put(t.getKey()+"date",CustomerExportData.date(v,t.getValue(),"yyyyMMdd"));v.put(t.getKey()+"time",CustomerExportData.date(v,t.getValue(),"HH:mm"));} + v.put("cr_sid"," "+right(text(v,"cr_sid"),3));v.put("cr_sid_parent"," "+right(text(v,"cr_sid_parent"),3));v.put("cr_eid",right(text(v,"cr_eid"),5));v.put("cr_eid_parent",right(text(v,"cr_eid_parent"),5));if(!text(v,"cr_eid_parent").isBlank())v.put("cr_eid",v.get("cr_eid_parent")); + long payer=id(v,"cash");v.put("cash",payer==-1||Long.toString(payer).equals(common.get("CSC_ID_PAYER_CASH"))?p.getOrDefault("JB_PAYMODE_CASH",""):" "); + String tax=text(v,"tax_rate_sign");v.put("tax_rate_sign",tax.equals("0")||hq>=100&&hq<300&&Set.of("3","4").contains(tax)?p.getOrDefault("JB_TAX_RATE_SIGN",""):" "); + v.put("cmp_inv2hq",Set.of(1L,3L).contains(id(v,"cmp_inv2hq"))?"RZ":" ");v.put("cmp_single_invoice",Set.of(2L,3L).contains(id(v,"cmp_single_invoice"))?"ER":" ");v.put("price_special",p.getOrDefault("EXPORT_CONST_PRICE_SPECIAL","FP"));String filter=text(v,"jb_cr_filter_opt");if(filter.contains("RZ"))v.put("cmp_inv2hq","RZ");if(filter.contains("EZ"))v.put("cmp_single_invoice","ER");if(filter.contains(",BL,"))v.put("price_special","BL"); + v.put("VAT_special"," ");v.put("prod_descr",p.getOrDefault("EXPORT_CONST_PRODUCT","K "));for(String key:List.of("contact_staffer","contact_person"))v.put(key,p.getOrDefault(key.equals("contact_staffer")?"EXPORT_CONST_STAFFER":"EXPORT_CONST_CONTACT_PERSON",""));for(int n=1;n<=3;n++)v.put("const_0"+n,p.getOrDefault("EXPORT_CONST_0"+n,"")); + long type=id(v,"vht_id"),kind=id(v,"jb_type"),parent=id(v,"jb_id_parent");String vehicle=hq>0&&hq<100&&type==999||type=1&&kind<=9)vehicle=List.of("K ","G ","M1","M3","M4","M5","M6","L ","G3").get((int)kind-1);if(hq>0&&hq<100&&(parent==-1||parent>0)&&Set.of("B ","K ","G ").contains(vehicle))vehicle=vehicle.charAt(0)+"K";v.put("vht_id",vehicle); + if(category==6)for(String key:List.of("price","jb_subtotalprice","jb_cr_price","jb_cr_subprice","jb_hq_id_exec_price"))v.put(key,number(v,key).abs()); + String toll=text(v,"jb_toll").strip().replace(',','.');try{v.put("jb_toll",toll.isBlank()?BigDecimal.ZERO:new BigDecimal(toll).abs());}catch(NumberFormatException e){v.put("jb_toll",BigDecimal.ZERO);} + for(String key:List.of("jb_third_country","jb_longhaul","jb_longhaul_export")){boolean flag;try{flag=key.equals("jb_third_country")?new BigDecimal(text(v,key).strip()).signum()>0:number(v,key).compareTo(BigDecimal.ONE)==0;}catch(NumberFormatException e){flag=false;}v.put(key,flag?"X":" ");} + String tour=text(v,"jb_tourdata");v.put("country_codes",tour.substring(tour.lastIndexOf('|')+1));if(text(v,"jb_commission_no").isBlank())v.put("jb_commission_no",v.get("tr_commission_no"));if(payer>0&&payer==id(v,"csc_id_root"))v.put("csc_name",""); + } + static String right(String s,int n){return s.substring(Math.max(0,s.length()-n));} + static BigDecimal number(Map row,String key){return BillingService.nz(decimal(row,key));} + static String marks(int size){return String.join(",",Collections.nCopies(size,"?"));} + static List concat(List a,List b){var out=new ArrayList(a);out.addAll(b);return out;} + static void unique(List> rows,String key,String label){if(rows.stream().map(r->id(r,key)).distinct().count()!=rows.size())throw bad("Mehrdeutige "+label+" im Export.");} + List> bounded(String sql,List ids,List evidence){var rows=db.queryForList(sql+" LIMIT 50001",ids.toArray());if(rows.size()>50000)throw bad("Zu viele Exportpositionen. Bitte die Auswahl eingrenzen.");evidence.add(rows);return rows;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AccountingExportFields.java b/backend/src/main/java/de/stadtbote/votian/AccountingExportFields.java new file mode 100644 index 0000000..8b4b0e1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AccountingExportFields.java @@ -0,0 +1,7 @@ +package de.stadtbote.votian; +import java.util.*; +/** Current server catalog 4/5/6; stored SQL is validated but never executed. */ +final class AccountingExportFields { + static final Map SQL=Map.ofEntries(Map.entry("inv.cmp_no","inv.cmp_no"),Map.entry("inv.prod_descr","inv.prod_descr"),Map.entry("inv.cr_eid","inv.cr_eid"),Map.entry("inv.cr_sid","inv.cr_sid"),Map.entry("inv.orderdate","inv.orderdate"),Map.entry("inv.ordertime","inv.ordertime"),Map.entry("inv.finishdate","inv.finishdate"),Map.entry("inv.finishtime","inv.finishtime"),Map.entry("inv.jb_id","inv.jb_id"),Map.entry("inv.VAT_special","inv.VAT_special"),Map.entry("inv.price_special","inv.price_special"),Map.entry("inv.price","inv.price"),Map.entry("inv.cs_eid1","inv.cs_eid1"),Map.entry("inv.cs_eid2","inv.cs_eid2"),Map.entry("inv.jb_invtext","inv.jb_invtext"),Map.entry("inv.cash","inv.cash"),Map.entry("inv.tax_rate","inv.tax_rate"),Map.entry("inv.tax_rate_sign","inv.tax_rate_sign"),Map.entry("inv.jb_freetext_1","inv.jb_freetext_1"),Map.entry("inv.jb_freetext_2","inv.jb_freetext_2"),Map.entry("inv.jb_freetext_3","inv.jb_freetext_3"),Map.entry("inv.jb_postage","inv.jb_postage"),Map.entry("inv.cmp_inv2hq","inv.cmp_inv2hq"),Map.entry("inv.cmp_single_invoice","inv.cmp_single_invoice"),Map.entry("inv.csc_name","inv.csc_name"),Map.entry("inv.csc_id_root","inv.csc_id_root"),Map.entry("inv.cr_eid_parent","inv.cr_eid_parent"),Map.entry("inv.cr_sid_parent","inv.cr_sid_parent"),Map.entry("inv.vht_id","inv.vht_id"),Map.entry("inv.jb_cr_filter","inv.jb_cr_filter"),Map.entry("inv.jb_cr_filter_opt","inv.jb_cr_filter_opt"),Map.entry("inv.jb_type","inv.jb_type"),Map.entry("inv.jb_id_parent","inv.jb_id_parent"),Map.entry("inv.const_01","inv.const_01"),Map.entry("inv.const_02","inv.const_02"),Map.entry("inv.const_03","inv.const_03"),Map.entry("inv.jb_cr_price","inv.jb_cr_price"),Map.entry("inv.jb_subtotalprice","inv.jb_subtotalprice"),Map.entry("inv.jb_markup","inv.jb_markup"),Map.entry("inv.jb_cr_subprice","inv.jb_cr_subprice"),Map.entry("inv.country_codes","inv.country_codes"),Map.entry("inv.jb_tourdata","inv.jb_tourdata"),Map.entry("inv.tr_commission_no","inv.tr_commission_no"),Map.entry("inv.jb_toll","inv.jb_toll"),Map.entry("inv.cscad1_comp","inv.cscad1_comp"),Map.entry("inv.cscad1_comp2","inv.cscad1_comp2"),Map.entry("inv.ad_cscad1_street","inv.ad_cscad1_street"),Map.entry("inv.ad_cscad1_hsno","inv.ad_cscad1_hsno"),Map.entry("inv.ad_cscad1_zipcode","inv.ad_cscad1_zipcode"),Map.entry("inv.ad_cscad1_city","inv.ad_cscad1_city"),Map.entry("inv.cscad1_comp3","inv.cscad1_comp3"),Map.entry("inv.cscad1_comp4","inv.cscad1_comp4"),Map.entry("inv.jb_third_country","inv.jb_third_country"),Map.entry("inv.jb_invtext_cr","inv.jb_invtext_cr"),Map.entry("inv.hq_mnemonic","inv.hq_mnemonic"),Map.entry("inv.cs_hq_mnemonic","inv.cs_hq_mnemonic"),Map.entry("inv.cr_hq_mnemonic","inv.cr_hq_mnemonic"),Map.entry("inv.jb_longhaul_export","inv.jb_longhaul_export"),Map.entry("inv.jb_hq_id_exec_price","inv.jb_hq_id_exec_price"),Map.entry("inv.jb_commission_no","inv.jb_commission_no"),Map.entry("inv.cscad1_gln","inv.cscad1_gln"),Map.entry("inv.cscad2_gln","inv.cscad2_gln"),Map.entry("inv.hq_gln","inv.hq_gln"),Map.entry("inv.jb_payment_term","inv.jb_payment_term")); + static String type(String key){return Set.of("price","gross_price","tax_rate","jb_postage","jb_cr_price","jb_subtotalprice","jb_markup","jb_cr_subprice","jb_toll","jb_hq_id_exec_price").contains(key)?"float":"text";} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AddedInstallation.java b/backend/src/main/java/de/stadtbote/votian/AddedInstallation.java new file mode 100644 index 0000000..5e69a20 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AddedInstallation.java @@ -0,0 +1,32 @@ +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 static de.stadtbote.votian.AdminSupport.*;import static de.stadtbote.votian.LegacyMapping.*;import static de.stadtbote.votian.OrderEntryService.fields;import static de.stadtbote.votian.BusinessSupport.*; +/** Zusatzmontage: explicit child accounting document instead of the missing PHP copy_job_flat. */ +@RestController +@RequestMapping("/api/orders/{job}/added-installation") +public final class AddedInstallation { + record Input(BigDecimal hours,Integer mode){} + record Command(String requestId,String revision,Input input,boolean confirmed){} + record State(OrderPayments.State source,List> links,List> childLinks,OrderOperations.Snapshot child,List> calculator){} + record Prepared(State state,Input input,AddedInstallationRates.Rates rates,BigDecimal net,BigDecimal courierNet,int mode,String tan){} + final OrderPayments payments;final OrderOperations orders;final DatabaseService db;final BusinessSupport support; + public AddedInstallation(OrderPayments payments){this.payments=payments;orders=payments.orders;db=payments.db;support=payments.support;} + static void access(LegacyUser u){u.require(4);u.require(7);} + static long link(List> rows,String key){var found=rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(key)).toList();if(found.size()>1)throw bad("Mehrdeutige Zusatzmontage-Zuordnung.");if(found.isEmpty()||text(found.getFirst(),"gdc_content").isBlank())return 0;try{long id=Long.parseLong(text(found.getFirst(),"gdc_content"));if(id<=0)throw new NumberFormatException();return id;}catch(Exception e){throw bad("Ungültige Zusatzmontage-Zuordnung.");}} + State state(LegacyUser u,long job,boolean lock){var s=payments.state(u,job,lock);String sql="SELECT gdc_id,gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_id_addmont','jb_id_addmont_parent') ORDER BY gdc_id";var links=lock?db.lockRows(sql,job):db.queryForList(sql,job);long child=link(links,"jb_id_addmont");if(child==job)throw bad("Eine Zusatzmontage darf nicht auf denselben Auftrag verweisen.");var childLinks=child==0?List.>of():lock?db.lockRows(sql,child):db.queryForList(sql,child);if(child>0&&link(childLinks,"jb_id_addmont_parent")!=job)throw bad("Die Rückverknüpfung des Zusatzauftrags stimmt nicht.");String calc="SELECT * FROM jobcalculator WHERE jb_id=? AND srvt_name='ident_9999:par_01' ORDER BY srv_name,srvt_name";return new State(s,links,childLinks,child==0?null:orders.snapshot(u,child,lock),lock?db.lockRows(calc,job):db.queryForList(calc,job));} + String blocked(LegacyUser u,State s){var source=s.source();var j=source.snapshot().job();if(!orders.catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))return "Die zugehörige Kundenabrechnung ist nicht aktiviert.";if(!OrderPayments.has(source,"jb_addmont")||(id(j,"jb_service")&2)!=2)return "Der Auftrag ist nicht für zusätzliche Montagezeit vorgesehen.";if(link(s.links(),"jb_id_addmont_parent")>0)return "Zusatzmontage bitte am ursprünglichen Montageauftrag bearbeiten.";if(id(j,"jb_status")!=2||id(j,"cr_id")<=0)return "Der Montageauftrag muss erledigt und einem Kurier zugeordnet sein.";if(id(j,"jb_offer")!=0)return "Angebote können nicht als Zusatzmontage gebucht werden.";if(OrderPayments.meta(source,"jb_no_payment_job").equals("1"))return "Der Auftrag wird vom Transporteur bezahlt.";if(source.allowed().isEmpty())return "Für diesen Kunden sind keine Montage-Zahlarten freigegeben.";if(id(source.customer(),"cs_id")<=0)return "Der zugehörige Kunde fehlt.";try{orders.writable(source.snapshot());if(s.child()!=null){orders.writable(s.child());if(id(s.child().job(),"jb_offer")!=0||id(s.child().job(),"csc_id_related")!=id(j,"csc_id_related"))return "Der Zusatzauftrag gehört nicht zur selben Abrechnung.";if(s.child().payments().size()>1)return "Der Zusatzauftrag enthält mehrere Zahlungen. Bitte zuerst die Zahlungszuordnung prüfen.";if(s.child().payments().stream().anyMatch(p->id(p,"jbpc_id")>0||OrderPayments.exportedPayment(p)))return "Die Zusatzmontage enthält bereits gesammelte oder exportierte Zahlungen.";}}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}return "";} + static String hours(State s){var m=s.source().metadata().stream().filter(r->text(r,"gdc_gen_fieldname").equals("jb_addmont")).findFirst().orElse(Map.of());String value=text(m,"gdc_content");return value.isBlank()?text(m,"gdc_context"):value;} + @GetMapping Map view(Authentication a,@PathVariable long job){return view(ApiController.user(a),job);} + Map view(LegacyUser u,long job){access(u);return db.readTransaction(()->{var s=state(u,job,false);String reason=blocked(u,s);return fields("hours",hours(s),"child",s.child()==null?0:id(s.child().job(),"jb_id"),"childNet",s.child()==null?BigDecimal.ZERO:OrderPayments.net(s.child().job().get("jb_totalprice")),"choices",s.source().allowed().stream().map(m->fields("id",m,"label",OrderPayments.label(s.source(),m))).toList(),"canEdit",reason.isEmpty(),"blocked",reason);});} + Prepared prepare(LegacyUser u,long job,Input i,boolean lock){access(u);if(i==null||i.hours()==null||i.hours().signum()<0||i.hours().compareTo(new BigDecimal("9999.99"))>0||i.hours().stripTrailingZeros().scale()>2||i.mode()==null)throw bad("Bitte 0 bis 9.999,99 Montagestunden und eine Zahlart auswählen.");var s=state(u,job,lock);String reason=blocked(u,s);if(!reason.isEmpty())throw bad(reason);if(!s.source().allowed().contains(i.mode()))throw bad("Die Montage-Zahlart ist nicht für diesen Kunden freigegeben.");boolean service=OrderPayments.meta(s.source(),"jb_service_job").equals("1"),online=!OrderPayments.meta(s.source(),"acquisitiontime").isBlank();int mode=service?15:i.mode();if(online){String name=service?"OS sad":"OS happy";var rows=db.queryForList("SELECT DISTINCT mt_sort FROM metatype WHERE mt_type='payment_type' AND mt_value=?",name);if(rows.size()!=1||id(rows.getFirst(),"mt_sort")<0||id(rows.getFirst(),"mt_sort")>127)throw bad("Die Online-Zahlart "+name+" ist nicht eindeutig konfiguriert.");mode=(int)id(rows.getFirst(),"mt_sort");}var rates=AddedInstallationRates.read(db,id(s.source().customer(),"cs_id"),service);var net=AddedInstallationRates.net(rates.customer(),i.hours(),false);var courier=AddedInstallationRates.net(rates.courier(),i.hours(),u.headquartersId()==1020);if(net.abs().compareTo(new BigDecimal("999999.99"))>0||courier.abs().compareTo(new BigDecimal("999999.99"))>0)throw bad("Der Montagebetrag überschreitet den zulässigen Auftragsbetrag.");return new Prepared(s,i,rates,net,courier,mode,online?Integer.toString(i.mode()):"");} + @PostMapping("/preview") Map preview(Authentication a,@PathVariable long job,@RequestBody Input i){return preview(ApiController.user(a),job,i);} + Map preview(LegacyUser u,long job,Input i){access(u);return db.readTransaction(()->{var p=prepare(u,job,i,false);return fields("revision",revision(p),"input",i,"hours",i.hours(),"child",p.state().child()==null?0:id(p.state().child().job(),"jb_id"),"customerRate",p.rates().customer().gross(),"courierRate",p.rates().courier().gross(),"customerCategory",p.rates().customer().category(),"courierCategory",p.rates().courier().category(),"net",p.net(),"gross",Cashflow.gross(p.net()),"courierNet",p.courierNet(),"mode",p.mode(),"modeLabel",OrderPayments.label(p.state().source(),p.mode()),"intendedMode",p.tan(),"fractionalCalculator",i.hours().stripTrailingZeros().scale()>0&&!p.state().calculator().isEmpty());});} + @PostMapping Map save(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);} + Map save(LegacyUser u,long job,Command c){access(u);if(c==null||!c.confirmed()||c.revision()==null||!c.revision().matches("[0-9a-f]{64}"))throw bad("Bitte die Zusatzmontage prüfen und bestätigen.");token(c.requestId());support.transactional("headquarters","job","tour","tourservice","jobpayment","jobcalculator","genericdatacontainer");return db.writeTransaction(()->{lockHeadquarters(db,u);var intent=List.of("ADDED_INSTALLATION",job,c);Long prior=support.receipt(u,c.requestId(),intent);if(prior!=null)return fields("saved",true,"child",prior,"replayed",true);var p=prepare(u,job,c.input(),true);unchanged(p,c.revision());long child=p.state().child()==null?create(u,job,p):id(p.state().child().job(),"jb_id");var now=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);update(db,"job","jb_id",child,fields("jb_id_parent",0,"jb_fixprice",p.net(),"jb_totalprice",p.net(),"jb_subtotalprice",p.net(),"jb_cr_price",p.courierNet(),"jb_cr_subprice",p.courierNet(),"jb_incomplete",0,"jb_status",2));db.update("UPDATE job SET jb_finishtime=COALESCE(NULLIF(jb_finishtime,'0000-00-00 00:00:00'),?),jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",now,child);db.update("UPDATE tourservice SET trs_price=? WHERE jb_id=?",p.net(),child); + var old=p.state().child()==null?List.>of():p.state().child().payments();var payment=fields("jbp_mode",p.mode(),"jbp_price",p.net(),"jbp_tan",p.tan(),"jbp_bookingtime",now);if(old.isEmpty()){payment.putAll(fields("jb_id",child,"tr_sort",2,"csc_id",id(p.state().source().snapshot().job(),"csc_id_related"),"jbp_counter",0,"jbpc_id",0));db.insert("jobpayment",payment);}else update(db,"jobpayment","jbp_id",id(old.getFirst(),"jbp_id"),payment); + db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_addmont'",c.input().hours().toPlainString(),job);db.update("UPDATE jobcalculator SET jbc_amount=?,jbc_price=?,jbc_totalprice=? WHERE jb_id=? AND srvt_name='ident_9999:par_01'",c.input().hours(),c.input().hours().signum()==0?BigDecimal.ZERO:p.net().divide(c.input().hours(),2,RoundingMode.HALF_UP),p.net(),job);db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",job);support.audit(u,job,id(p.state().source().customer(),"cs_id"),id(p.state().source().snapshot().job(),"cr_id"),118,"Zusatzmontage #"+child+" · Stunden "+c.input().hours()+" · Netto "+p.net()+" · Frachtvergütung "+p.courierNet()+" · Zahlart "+p.mode(),c.requestId());support.receipt(u,c.requestId(),intent,child);return fields("saved",true,"child",child);});} + long create(LegacyUser u,long parent,Prepared p){var source=p.state().source().snapshot().job();var copy=new LinkedHashMap<>(source);for(String key:List.of("jb_id","jb_modify","jb_export_time","jb_locktime","jb_lockuser","jb_id_copy"))copy.remove(key);copy.putAll(fields("jb_id_parent",0,"jb_id_copy",0,"jb_storno",0,"jb_offer",0,"jb_autoranking",0,"jb_globaljob",0,"jb_permanent",0,"jb_reserv",0,"jb_version",0,"jb_createtime",LocalDateTime.now(),"jb_booktime",LocalDateTime.now(),"emp_id",u.userId(),"jb_invtext","Zusätzliche Montage zu Auftrag "+parent,"jb_invtext_cr","Zusätzliche Montage zu Auftrag "+parent,"jb_serviceprice",0,"jb_markup",0,"jb_postage",0,"jb_automailsent",0,"jb_automailsent2",0,"jb_automailsent3",0));long child=db.insert("job",copy);for(var t:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",parent)){var next=new LinkedHashMap<>(t);next.remove("tr_id");next.remove("tr_modify");next.put("jb_id",child);db.insert("tour",next);}insertWithoutKey(db,"tourservice",fields("jb_id",child,"tr_sort",0,"csc_id",id(source,"csc_id_related"),"srv_id",0,"srvt_id",0,"trs_srv_name","Zusätzliche Montage","trs_srvt_name","ident_9999:par_01","trs_price",p.net(),"trs_discount",0));metadata(parent,"jb_id_addmont",Long.toString(child));metadata(child,"jb_id_addmont_parent",Long.toString(parent));if(p.mode()==15)metadata(child,"jb_service_job","1");OrderPaymentTerms.copy(db,parent,child);return child;} + void metadata(long job,String key,String value){var old=db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,key);if(old.size()>1)throw bad("Mehrdeutiges Montagefeld.");if(!old.isEmpty()){db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_id=?",value,id(old.getFirst(),"gdc_id"));return;}db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context",""));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AddedInstallationRates.java b/backend/src/main/java/de/stadtbote/votian/AddedInstallationRates.java new file mode 100644 index 0000000..137eef8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AddedInstallationRates.java @@ -0,0 +1,20 @@ +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 static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.*; +/** ident_0001 and the five promotion slots from current inc_mtf_func.inc.php. */ +final class AddedInstallationRates { + record Rate(int category,BigDecimal gross,BigDecimal repayment){} + record Rates(List> evidence,LocalDate day,Rate customer,Rate courier){} + static Rates read(DatabaseService db,long customer,boolean service){var rows=db.queryForList("SELECT ck.mtfc_id,k.mtfk_type,v.mtfv_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 v.mtfv_id=? AND ck.hq_id=0 AND ck.mtfc_id BETWEEN 20 AND 45 AND k.mtfk_type LIKE 'func:ident_0001:%' ORDER BY ck.mtfc_id,k.mtfk_type,v.mtfv_value",customer);var day=LocalDate.now(ZoneId.of("Europe/Berlin"));return new Rates(rows,day,select(rows,service?40:20,day),select(rows,service?40:30,day));} + static BigDecimal number(String v){try{if(!v.strip().matches("[+-]?[0-9]+(?:[.,][0-9]+)?"))throw new NumberFormatException();return new BigDecimal(v.strip().replace(',','.'));}catch(Exception e){throw bad("Ein Betrag im Montagepreiskatalog ist ungültig.");}} + static Rate select(List> rows,int base,LocalDate day){for(int category=base+1;category<=base+5;category++){var r=one(rows,category,day);if(r!=null)return r;}var r=one(rows,base,day);if(r==null)throw bad("Kein aktiver Montage-Stundensatz in Kategorie "+base+" vorhanden.");return r;} + static Rate one(List> rows,int category,LocalDate day){var values=new LinkedHashMap();for(var row:rows)if(id(row,"mtfc_id")==category){String[] parts=text(row,"mtfk_type").split(":",-1);if(parts.length<3||!parts[1].equals("ident_0001"))continue;if(values.put(parts[2],text(row,"mtfv_value"))!=null)throw bad("Mehrdeutige Montagepreisvorgabe in Kategorie "+category+".");} + if(values.isEmpty()||values.containsKey("activated")&&!values.get("activated").equals("1"))return null; + // PHP uses numeric dmY comparisons here, not a chronological date comparison. + if(values.containsKey("date_valid")){String[] range=values.get("date_valid").split("\\|",-1);if(range.length!=2||!range[0].matches("[0-9]{8}")||!range[1].matches("[0-9]{8}"))throw bad("Ungültiger Montage-Aktionszeitraum in Kategorie "+category+".");long current=Long.parseLong(day.format(DateTimeFormatter.ofPattern("ddMMyyyy")));if(currentLong.parseLong(range[1]))return null;} + String value=values.getOrDefault("01","");if(value.isBlank())return null;var gross=number(value);if(gross.signum()<0||gross.compareTo(new BigDecimal("999999.99"))>0)throw bad("Ungültiger Montage-Stundensatz.");String percent=values.getOrDefault("global_repayment","");return new Rate(category,gross,percent.isBlank()?BigDecimal.ZERO:gross.multiply(number(percent)).movePointLeft(2)); + } + static BigDecimal net(Rate r,BigDecimal hours,boolean deduct){return (deduct?r.gross().subtract(r.repayment()):r.gross()).multiply(hours).divide(new BigDecimal("1.19"),2,RoundingMode.HALF_UP);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AdministrationController.java b/backend/src/main/java/de/stadtbote/votian/AdministrationController.java index 40856d4..7c34507 100644 --- a/backend/src/main/java/de/stadtbote/votian/AdministrationController.java +++ b/backend/src/main/java/de/stadtbote/votian/AdministrationController.java @@ -15,7 +15,7 @@ public class AdministrationController { public AdministrationController(CalendarAdministration calendar,GroupAdministration groups,GroupDeletion deletion){this.calendar=calendar;this.groups=groups;this.deletion=deletion;} @GetMapping Map sections(Authentication auth) { var u=user(auth); - return OrderEntryService.fields("postalPrices",u.hasRight(2),"postalDiscounts",u.hasRight(2),"areaPrices",u.hasRight(2),"areaDiscounts",u.hasRight(2),"servicePrices",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"serviceDiscounts",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"postalAddresses",u.hasRight(2),"areaAddresses",u.hasRight(2),"headquartersId",u.headquartersId(),"holidays",u.hasRight(2),"travelTimes",u.hasRight(2),"groups",u.hasRight(8)&&u.hasRight(25),"sharedGroups",u.hasRight(10),"evaluations",u.hasRight(0)&&u.hasRight(8),"filters",u.hasRight(0),"customerReports",u.hasRight(0)&&u.hasRight(16),"courierReports",u.hasRight(1)&&u.hasRight(16),"groupKinds",Arrays.stream(GroupAdministration.Kind.values()).filter(k->u.hasRight(k.right)&&(k.hq!=null||u.hasRight(10))).map(k->Map.of("id",k.name(),"label",k.label)).toList()); + return OrderEntryService.fields("operations",OperationsMonitoring.allowed(u),"warehouseUsers",u.hasRight(3)&&u.headquartersId()==u.originalHeadquartersId(),"warehouses",u.hasRight(14),"articles",u.hasRight(14),"headquartersSettings",u.headquartersId()==u.originalHeadquartersId(),"employees",u.hasRight(3),"pdaSettings",u.hasRight(9),"vehicleDisposition",u.hasRight(27)||u.hasRight(28),"fleetMap",u.hasRight(19),"extras",u.hasRight(46),"postalPrices",u.hasRight(2),"postalDiscounts",u.hasRight(2),"areaPrices",u.hasRight(2),"areaDiscounts",u.hasRight(2),"servicePrices",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"serviceDiscounts",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"postalAddresses",u.hasRight(2),"areaAddresses",u.hasRight(2),"headquartersId",u.headquartersId(),"holidays",u.hasRight(2),"travelTimes",u.hasRight(2),"groups",u.hasRight(8)&&u.hasRight(25),"sharedGroups",u.hasRight(10),"evaluations",u.hasRight(0)&&u.hasRight(8),"filters",u.hasRight(0),"customerReports",u.hasRight(0)&&u.hasRight(16),"courierReports",u.hasRight(1)&&u.hasRight(16),"groupKinds",Arrays.stream(GroupAdministration.Kind.values()).filter(k->u.hasRight(k.right)&&(k.hq!=null||u.hasRight(10))).map(k->Map.of("id",k.name(),"label",k.label)).toList()); } @GetMapping("/holidays/{year}") Map holidays(Authentication auth,@PathVariable int year){return calendar.holidays(user(auth),year);} @PutMapping("/holidays/{year}") Map holidays(Authentication auth,@PathVariable int year,@RequestBody HolidaySave request){return calendar.saveHolidays(user(auth),year,request);} diff --git a/backend/src/main/java/de/stadtbote/votian/AllergoImport.java b/backend/src/main/java/de/stadtbote/votian/AllergoImport.java new file mode 100644 index 0000000..4f753df --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AllergoImport.java @@ -0,0 +1,34 @@ +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.*;import java.nio.charset.StandardCharsets;import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** ALLERGO's active 46-column shipment import, with customer-scoped name transfer. */ +@RestController @RequestMapping("/api/allergo-import/{customer}") +class AllergoImport { + record Command(String requestId,String csv,LocalDate date,String revision,boolean replaceNames){} + record Address(String name,String street,String postal,String city,String country,String contact,String phone,String signer){} + record Shipment(int line,String transaction,Address sender,Address recipient,String remark){} + record Item(Shipment shipment,boolean existing){} + record Plan(long headquarters,long customer,LocalDate date,TourCsvImport.State state,List items,List> markers,List> renamed){} + final DatabaseService db;final BusinessSupport support;final TourCsvImport tours;final Clock clock; + @org.springframework.beans.factory.annotation.Autowired AllergoImport(DatabaseService db,BusinessSupport support,TourCsvImport tours){this(db,support,tours,Clock.system(ZoneId.of("Europe/Berlin")));} + AllergoImport(DatabaseService db,BusinessSupport support,TourCsvImport tours,Clock clock){this.db=db;this.support=support;this.tours=tours;this.clock=clock;} + @GetMapping Object options(Authentication a,@PathVariable long customer){return options(ApiController.user(a),customer);} + @PostMapping("/preview") Object preview(Authentication a,@PathVariable long customer,@RequestBody Command c){return preview(ApiController.user(a),customer,c);} + @PostMapping Object save(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + static String value(List v,int col,int max,int line){return ZoneJobImport.value(v.get(col),max,"Spalte "+(col+1),line);} + 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());});} + 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);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ApiController.java b/backend/src/main/java/de/stadtbote/votian/ApiController.java index 66d7ef6..02c7c6f 100644 --- a/backend/src/main/java/de/stadtbote/votian/ApiController.java +++ b/backend/src/main/java/de/stadtbote/votian/ApiController.java @@ -42,6 +42,7 @@ public class ApiController { result.put("message",connection.message());result.put("reason",connection.reason()); result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults()); result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities()); + result.put("cashflow",u!=null&&u.hasRight(26)); result.put("unavailableModules",List.of("Fibu-Zahlungsabgleich","Archivtabellen")); return result; } @@ -49,22 +50,41 @@ public class ApiController { return userDetails(authentication); } static Map userDetails(Authentication authentication) { - LegacyUser u=user(authentication);return Map.of("username",u.username(),"roles",List.of("VIEWER"),"headquartersId",u.headquartersId(),"userId",u.userId(),"capabilities",u.capabilities()); + return userDetails(user(authentication)); } - @GetMapping("/orders") public ResponseEntity> orders(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) { - var result=legacy.orders(user(auth),q,status,customerId,courierId,from,to); + static Map userDetails(LegacyUser u) { + return Map.of("originalHeadquartersId",u.originalHeadquartersId(),"username",u.username(),"accountType",u.accountType(),"roles",List.of(u.transferAccount()?"TRANSFER":u.customerAccount()?"CUSTOMER":u.courierAccount()?"COURIER":u.warehouseAccount()?"WAREHOUSE":"VIEWER"),"headquartersId",u.headquartersId(),"userId",u.userId(),"capabilities",u.capabilities()); + } + public ResponseEntity> orders(Authentication auth,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,boolean personal,Long numberFrom,Long numberTo,Integer vehicleType,int offset,Integer listStatus) {return orders(auth,q,status,customerId,courierId,from,to,personal,numberFrom,numberTo,vehicleType,offset,listStatus,null);} + @GetMapping("/orders") public ResponseEntity> orders(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to,@RequestParam(defaultValue="false") boolean personal,@RequestParam(required=false) Long numberFrom,@RequestParam(required=false) Long numberTo,@RequestParam(required=false) Integer vehicleType,@RequestParam(defaultValue="0") int offset,@RequestParam(required=false) Integer listStatus,@RequestParam(required=false) String filters) { + if(filters!=null&&!filters.isBlank()&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Spezialfilter benötigen persönliche Listen."); + if(listStatus!=null&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Listenstatus benötigt persönliche Listenfilter."); + var result=personal?legacy.personalOrders(user(auth),q,status,customerId,courierId,from,to,new LegacyReadService.ListQuery(numberFrom,numberTo,vehicleType,offset,listStatus,null,false,false,OrderListFilters.parse(filters))):legacy.orders(user(auth),q,status,customerId,courierId,from,to); return ResponseEntity.ok().header("X-Total-Count",Long.toString(result.total())).header("X-Result-Limit",Integer.toString(result.limit())).body(result.orders()); } + public LegacyReadService.OrderResult orderList(Authentication auth,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,Long numberFrom,Long numberTo,Integer vehicleType,int offset,Integer listStatus,String sort,boolean descending) {return orderList(auth,q,status,customerId,courierId,from,to,numberFrom,numberTo,vehicleType,offset,listStatus,sort,descending,null);} + @GetMapping("/orders/list") public LegacyReadService.OrderResult orderList(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to,@RequestParam(required=false) Long numberFrom,@RequestParam(required=false) Long numberTo,@RequestParam(required=false) Integer vehicleType,@RequestParam(defaultValue="0") int offset,@RequestParam(required=false) Integer listStatus,@RequestParam(required=false) String sort,@RequestParam(defaultValue="false") boolean descending,@RequestParam(required=false) String filters) { + return legacy.personalOrders(user(auth),q,status,customerId,courierId,from,to,new LegacyReadService.ListQuery(numberFrom,numberTo,vehicleType,offset,listStatus,sort,descending,true,OrderListFilters.parse(filters))); + } @GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); } @GetMapping("/orders/{id}/history") public List history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); } @GetMapping("/customers") public List customers(Authentication auth,@RequestParam(required=false) String q,@RequestParam(defaultValue="false") boolean showCostCenters,@RequestParam(required=false) String costCenterName) { return legacy.customers(user(auth),q,showCostCenters,costCenterName); } @GetMapping("/couriers") public List couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); } - @GetMapping("/orders/export.csv") public ResponseEntity csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) { - LegacyUser u=user(auth);u.require(5); - var result=legacy.orders(u,q,status,customerId,courierId,from,to); + public ResponseEntity csv(Authentication auth,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,boolean personal,Long numberFrom,Long numberTo,Integer vehicleType,Integer listStatus,boolean columns,String sort,boolean descending) {return csv(auth,q,status,customerId,courierId,from,to,personal,numberFrom,numberTo,vehicleType,listStatus,columns,sort,descending,null);} + @GetMapping("/orders/export.csv") public ResponseEntity csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to,@RequestParam(defaultValue="false") boolean personal,@RequestParam(required=false) Long numberFrom,@RequestParam(required=false) Long numberTo,@RequestParam(required=false) Integer vehicleType,@RequestParam(required=false) Integer listStatus,@RequestParam(defaultValue="false") boolean columns,@RequestParam(required=false) String sort,@RequestParam(defaultValue="false") boolean descending,@RequestParam(required=false) String filters) { + LegacyUser u=user(auth);u.require(5);if(filters!=null&&!filters.isBlank()&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Spezialfilter benötigen persönliche Listen."); + if(listStatus!=null&&!personal)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Listenstatus benötigt persönliche Listenfilter."); + var result=personal?legacy.personalOrders(u,q,status,customerId,courierId,from,to,new LegacyReadService.ListQuery(numberFrom,numberTo,vehicleType,0,listStatus,sort,descending,columns,OrderListFilters.parse(filters))):legacy.orders(u,q,status,customerId,courierId,from,to); if(result.total()>result.limit())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Der Export umfasst mehr als "+result.limit()+" Aufträge. Bitte den Zeitraum oder die Filter eingrenzen; ein unvollständiger Export wird nicht erstellt."); - StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Status;Kunde-ID;Kurier-ID;Abholung;Referenz;Netto;Kurierpreis;MwSt\n"); + StringBuilder csv; + if(personal&&columns){ + csv=new StringBuilder("\uFEFF"); + csv.append(result.columns().stream().map(c->csvCell(c.label())).collect(java.util.stream.Collectors.joining(";"))).append('\n'); + for(var o:result.orders())csv.append(result.columns().stream().map(c->csvCell(OrderListCells.ACTIONS.contains(c.key())?"":o.listCells.get(c.key()))).collect(java.util.stream.Collectors.joining(";"))).append('\n'); + } else { + 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)); } 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("\"","\"\"")+'"'; } diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentController.java b/backend/src/main/java/de/stadtbote/votian/AppointmentController.java index f151418..1322d88 100644 --- a/backend/src/main/java/de/stadtbote/votian/AppointmentController.java +++ b/backend/src/main/java/de/stadtbote/votian/AppointmentController.java @@ -11,6 +11,8 @@ public class AppointmentController { public AppointmentController(AppointmentService service) { this.service=service; } @GetMapping("/options") Map options(Authentication auth) { return service.options(user(auth)); } @GetMapping("/customers") List> customers(Authentication auth,@RequestParam(defaultValue="") String q) { return service.customers(user(auth),q); } + @GetMapping("/customers/{id}/context") Map customerContext(Authentication auth,@PathVariable long id) { return service.customerContext(user(auth),id); } + @GetMapping("/{id}") Map get(Authentication auth,@PathVariable long id) { return service.get(user(auth),id); } @PostMapping("/query") Map list(Authentication auth,@RequestBody Query query) { return service.list(user(auth),query); } @PostMapping("/statistics") Map statistics(Authentication auth,@RequestBody Query query) { return service.statistics(user(auth),query); } @PostMapping Map create(Authentication auth,@RequestBody Save request) { return service.create(user(auth),request); } diff --git a/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java b/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java index 1c2859c..ec57b0c 100644 --- a/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java +++ b/backend/src/main/java/de/stadtbote/votian/AppointmentMailTransport.java @@ -10,9 +10,25 @@ class AppointmentMailTransport { 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();} 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);} + 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);} + 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); }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); + 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); + }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 047b998..da3093d 100644 --- a/backend/src/main/java/de/stadtbote/votian/AppointmentService.java +++ b/backend/src/main/java/de/stadtbote/votian/AppointmentService.java @@ -75,13 +75,21 @@ public class AppointmentService { if(!members.isEmpty())groupViews.add(fields("id",id(g,"id"),"label",text(g,"label"),"members",members)); } return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"currentUser",user.userId(),"now",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0),"categories",categories(),"people",people,"groups",groupViews, - "defaultMonitoring",catalog.enabled(0,"MASK_AP_CATEGORY_4_PRESET")?1:0,"miscType",catalog.parameter(0,"MASK_AP_CATEGORY_3_MISC_ITEM"),"canReadReports",user.hasRight(16)); + "defaultMonitoring",catalog.enabled(0,"MASK_AP_CATEGORY_4_PRESET")?1:0,"miscType",catalog.parameter(0,"MASK_AP_CATEGORY_3_MISC_ITEM"),"canReadReports",user.hasRight(16),"canStatistics",user.hasRight(8),"showCustomerReports",catalog.enabled(0,"MASK_AP_BTN_CS_REPORT"),"canCreateCustomer",user.hasRight(0)&&catalog.enabled(0,"MASK_AP_BTN_CS_NEW")); } public List> customers(LegacyUser user,String q) { access(user);if(q==null||q.length()>100)throw bad("Suchtext zu lang.");var hqs=allowedHeadquarters(user); var args=new ArrayList(hqs);args.add("%"+q.strip()+"%"); return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id IN ("+marks(hqs.size())+") AND CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ? ORDER BY label,id LIMIT 50",args.toArray()); } + public Map customerContext(LegacyUser user,long customerId) { + access(user);return db.readTransaction(()->{ + var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(customerId));args.addAll(hqs); + var row=one(db.queryForList("SELECT c.cs_id AS id,c.cs_eid AS number,c.hq_id AS headquartersId,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.cs_id=? AND c.hq_id IN ("+marks(hqs.size())+")",args.toArray())); + boolean own=id(row,"headquartersId")==user.headquartersId(); + return fields("id",id(row,"id"),"number",text(row,"number"),"headquartersId",id(row,"headquartersId"),"label",text(row,"label"),"canOpenMaster",own,"canStatistics",user.hasRight(8),"masterUnavailableReason",own?"":"Der Kundenstamm wird in der Niederlassung des Kunden gepflegt."); + }); + } private void customer(LegacyUser user,long customerId) { var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(customerId));args.addAll(hqs); one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN ("+marks(hqs.size())+")",args.toArray())); @@ -131,6 +139,14 @@ public class AppointmentService { return fields("items",rows.stream().map(r->view(user,r,names)).toList(),"total",rows.size(),"from",query.from(),"to",query.to()); }); } + public Map get(LegacyUser user,long appointmentId) { + access(user);return db.readTransaction(()->{ + var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(appointmentId));args.addAll(hqs); + String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args); + var row=one(db.queryForList("SELECT "+selectColumns()+",CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS customer_label"+joins()+" WHERE "+where,args.toArray())); + return view(user,row,participantNames(user,List.of(row))); + }); + } private Map participantNames(LegacyUser user,List> rows) { var ids=new LinkedHashSet();for(var row:rows)ids.addAll(GroupAdministration.memberships(text(row,"ap_participants"))); var names=new HashMap();if(ids.isEmpty())return names; diff --git a/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java b/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java new file mode 100644 index 0000000..153e21f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ArticleAdministration.java @@ -0,0 +1,84 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.math.BigInteger; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Standalone PHP article_special/article_list workflows; no warehouse movement is implicit. */ +@RestController +@RequestMapping("/api/administration/articles") +class ArticleAdministration { + final DatabaseService db;final BusinessSupport support; + ArticleAdministration(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + record Values(String eid,String name,String additional,String description,String barcode,boolean serial,boolean mountable,int packages,String packageCode,int stockItems,int stockPlaces,long group,boolean enabled,boolean visible){} + record Command(String requestId,long id,String action,Values values,String childEid,int quantity,String revision,String planRevision){} + record State(Map row,List> links,List> bundles,List> stock,List> serials,Map settings){} + record Plan(Map before,Map after,List> changes,List evidence,String action){} + @GetMapping Map get(Authentication a,@RequestParam(required=false) Long headquarters,@RequestParam(defaultValue="") String eid,@RequestParam(defaultValue="") String name,@RequestParam(defaultValue="") String additional,@RequestParam(defaultValue="") String description,@RequestParam(defaultValue="") String barcode,@RequestParam(defaultValue="false") boolean contains,@RequestParam(defaultValue="false") boolean hidden,@RequestParam(defaultValue="0") int offset,@RequestParam(defaultValue="at_name") String sort){return list(ApiController.user(a),headquarters,List.of(eid,name,additional,description,barcode),contains,hidden,offset,sort);} + @GetMapping("/options") Map options(Authentication a,@RequestParam(required=false) Long headquarters){return options(ApiController.user(a),headquarters);} + @GetMapping("/{id}") Map detail(Authentication a,@PathVariable long id,@RequestParam(required=false) Long headquarters){return view(ApiController.user(a),headquarters,id);} + @GetMapping("/{id}/history") Map history(Authentication a,@PathVariable long id,@RequestParam(required=false) Long headquarters,@RequestParam(defaultValue="0") int offset){return history(ApiController.user(a),headquarters,id,offset);} + @PostMapping("/preview") Map preview(Authentication a,@RequestParam(required=false) Long headquarters,@RequestBody Command c){return preview(ApiController.user(a),headquarters,c);} + @PostMapping("/save") Map save(Authentication a,@RequestParam(required=false) Long headquarters,@RequestBody Command c){return save(ApiController.user(a),headquarters,c);} + LegacyUser scoped(LegacyUser u,Long target){if(u.warehouseAccount()){WarehousePortalScope.articles(db,u);if(target!=null&&target!=u.headquartersId())throw MasterDirectoryScope.denied();return u;}var access=MasterDirectoryScope.access(db,u,14);long hq=target==null?u.headquartersId():target;MasterDirectoryScope.select(u,access,List.of(hq));return new LegacyUser(u.userId(),u.employeeId(),hq,u.username(),u.rights(),u.originalHeadquartersId());} + List> branches(LegacyUser u){if(!u.warehouseAccount())return MasterDirectoryScope.access(db,u,14).branches();WarehousePortalScope.articles(db,u);return db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id=?",u.headquartersId());} + Map settings(LegacyUser u){var result=new LinkedHashMap();for(String key:List.of("AT_EID_PREFIX","AT_EID_GENERATION","AT_EID_EDITABLE")){var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=?",u.headquartersId(),key);if(rows.size()>1)throw bad("Mehrdeutige Artikelvorgabe: "+key);result.put(key,rows.isEmpty()?"":text(rows.getFirst(),"par_value"));}if(!Set.of("","0","1").contains(text(result,"AT_EID_EDITABLE")))throw bad("Ungültige Vorgabe zur Artikelnummer.");return result;} + List> groups(LegacyUser u){return db.queryForList("SELECT atg_id AS id,atg_key AS code,atg_name AS name,hq_id AS headquarters FROM articlegroup WHERE md_id=? AND hq_id IN (0,?) ORDER BY atg_key,atg_id",mandator(db,u),u.headquartersId());} + String suggestion(Map settings){String bound=text(settings,"AT_EID_GENERATION"),prefix=text(settings,"AT_EID_PREFIX");if(bound.isBlank())return "";String max=db.queryForObject("SELECT MAX(at_eid) FROM article WHERE at_eid20)throw bad("Der Nummernvorschlag überschreitet 20 Zeichen.");return next;} + Map options(LegacyUser user,Long hq){return db.readTransaction(()->{var u=scoped(user,hq);var s=settings(u);return fields("headquarters",u.headquartersId(),"branches",branches(user),"groups",groups(u),"settings",s,"suggestedEid",suggestion(s));});} + Map list(LegacyUser user,Long hq,List filters,boolean contains,boolean hidden,int offset){return list(user,hq,filters,contains,hidden,offset,"at_name");} + Map list(LegacyUser user,Long hq,List filters,boolean contains,boolean hidden,int offset,String sort){return db.readTransaction(()->{ + var u=scoped(user,hq);page(offset);if(filters.size()!=5)throw bad("Ungültige Artikelsuche."); + if(!ArticleColumns.KEYS.contains(sort)||Set.of("at_id_history","at_id_edit").contains(sort))throw bad("Ungültige Artikelsortierung."); + var layout=ArticleColumns.runtime(db,user);var args=new ArrayList();args.add(mandator(db,u));args.add(u.headquartersId());args.add(u.headquartersId());String where="a.hq_id=?"+(hidden?"":" AND a.at_visible=1"); + var columns=List.of("at_eid","at_name","at_match","at_description","at_barcode");for(int i=0;i<5;i++){String q=clean(filters.get(i),100,"Suchtext");if(!q.isEmpty()){where+=" AND a."+columns.get(i)+" LIKE ? ESCAPE '!'";args.add((contains?"%":"")+q.replace("!","!!").replace("%","!%").replace("_","!_")+"%");}}args.add(offset); + String group="(SELECT GROUP_CONCAT(DISTINCT CONCAT(g.atg_key,' · ',g.atg_name) ORDER BY g.atg_key SEPARATOR ' / ') FROM articlegroupitem gi JOIN articlegroup g ON g.md_id=gi.md_id AND g.atg_id=gi.atg_id WHERE gi.at_id=a.at_id AND gi.md_id=? AND gi.hq_id IN (0,?) AND g.hq_id IN (0,a.hq_id))"; + var rows=db.queryForList("SELECT a.at_id AS id,a.at_eid AS eid,a.at_name AS name,a.at_match AS additional,a.at_description AS description,a.at_barcode AS barcode,a.at_serialno AS serial,a.at_mountable AS mountable,a.at_visible AS visible,a.at_authenticated AS enabled,DATE_FORMAT(a.at_createtime,'%Y-%m-%d %H:%i') AS created,"+group+" AS atg_data FROM article a WHERE "+where+" ORDER BY "+(sort.equals("atg_data")?"atg_data":"a."+sort)+",a.at_id LIMIT 51 OFFSET ?",args.toArray()); + return fields("rows",rows.stream().limit(50).toList(),"hasMore",rows.size()>50,"offset",offset,"headquarters",u.headquartersId(),"sort",sort,"columns",layout.columns(),"definitions",ArticleColumns.definitions(),"warning",layout.warning()); + });} + State state(LegacyUser u,long article){var row=article==0?Map.of():one(db.queryForList("SELECT * FROM article WHERE at_id=? AND hq_id=?",article,u.headquartersId()));return new State(row,article==0?List.of():db.queryForList("SELECT * FROM articlegroupitem WHERE at_id=? AND md_id=? AND hq_id IN (0,?) ORDER BY hq_id,atg_id",article,mandator(db,u),u.headquartersId()),article==0?List.of():db.queryForList("SELECT * FROM articlebundle WHERE at_id=? OR at_id2=? ORDER BY at_id,at_id2",article,article),article==0?List.of():db.queryForList("SELECT * FROM stockarticle WHERE at_id=? ORDER BY stk_id",article),article==0?List.of():db.queryForList("SELECT ati_id FROM articleitem WHERE at_id=? ORDER BY ati_id",article),settings(u));} + Values values(State s){var r=s.row();long group=s.links().isEmpty()?0:id(s.links().getFirst(),"atg_id");if(r.isEmpty())return new Values(text(s.settings(),"AT_EID_PREFIX"),"","","","",false,false,1,"",1,1,0,true,true);return new Values(text(r,"at_eid"),text(r,"at_name"),text(r,"at_match"),text(r,"at_description"),text(r,"at_barcode"),id(r,"at_serialno")==1,id(r,"at_mountable")==1,(int)id(r,"at_bundlequantity"),text(r,"at_bundlecode"),(int)id(r,"at_stk_itemquantity"),(int)id(r,"at_stk_areaquantity"),group,id(r,"at_authenticated")==1,id(r,"at_visible")==1);} + Map view(LegacyUser user,Long hq,long article){return db.readTransaction(()->{var u=scoped(user,hq);if(article<0)throw bad("Ungültiger Artikel.");var s=state(u,article);var children=new ArrayList>();for(var b:s.bundles())if(id(b,"at_id")==article){var child=db.queryForList("SELECT at_id,at_eid,at_name,hq_id FROM article WHERE at_id=?",id(b,"at_id2"));boolean allowed=child.size()==1&&branches(user).stream().anyMatch(h->id(h,"id")==id(child.getFirst(),"hq_id"));children.add(fields("id",id(b,"at_id2"),"eid",allowed?text(child.getFirst(),"at_eid"):"","name",allowed?text(child.getFirst(),"at_name"):"Nicht zugänglicher Bestandteil","quantity",id(b,"atb_bundlequantity")));}return fields("id",article,"headquarters",u.headquartersId(),"values",values(s),"revision",revision(s),"settings",s.settings(),"groups",groups(u),"bundle",children,"groupAmbiguous",s.links().size()>1,"groupShared",!s.links().isEmpty()&&id(s.links().getFirst(),"hq_id")==0);});} + static String clean(String s,int limit,String label){s=s==null?"":s.strip();if(s.length()>limit||s.indexOf('\u0000')>=0)throw bad(label+": höchstens "+limit+" Zeichen.");return s;} + Values validated(Values v){if(v==null)throw bad("Artikeldaten fehlen.");String description=v.description()==null?"":v.description();if(description.getBytes(StandardCharsets.UTF_8).length>65535||description.indexOf('\u0000')>=0)throw bad("Beschreibung überschreitet die Datenbankgrenze von 65535 Bytes.");if(v.packages()<0||v.packages()>32767||v.stockItems()<1||v.stockItems()>8388607||v.stockPlaces()<1||v.stockPlaces()>127||v.group()<0)throw bad("Ungültige Gebinde- oder Lagereinheiten (Lagerplätze 1–127).");return new Values(required(clean(v.eid(),20,"ExtID"),20,"ExtID"),required(clean(v.name(),50,"Bezeichnung"),50,"Bezeichnung"),clean(v.additional(),100,"Zusatz"),description,clean(v.barcode(),50,"Barcode"),v.serial(),v.mountable(),v.packages(),clean(v.packageCode(),50,"Gebindecode"),v.stockItems(),v.stockPlaces(),v.group(),v.enabled(),v.visible());} + Map data(Values v){return fields("at_eid",v.eid(),"at_name",v.name(),"at_match",v.additional(),"at_description",v.description(),"at_barcode",v.barcode(),"at_serialno",v.serial()?1:0,"at_mountable",v.mountable()?1:0,"at_bundlequantity",v.packages(),"at_bundlecode",v.packageCode(),"at_stk_itemquantity",v.stockItems(),"at_stk_areaquantity",v.stockPlaces(),"at_authenticated",v.enabled()?1:0,"at_visible",v.visible()?1:0,"group",v.group());} + Plan plan(LegacyUser user,LegacyUser u,Command c){if(c==null||c.id()<0)throw bad("Ungültiger Artikelvorgang.");BusinessSupport.token(c.requestId());var s=state(u,c.id());unchanged(s,c.revision());var before=c.id()==0?Map.of():data(values(s));var after=new LinkedHashMap(before);var evidence=new ArrayList();evidence.add(s);String action=c.action(); + if("SAVE".equals(action)){ + var v=validated(c.values());after=new LinkedHashMap<>(data(v));if(v.eid().equals(text(s.settings(),"AT_EID_PREFIX")))throw bad("Bitte eine vollständige Artikelnummer eingeben."); + if(c.id()>0&&!text(s.settings(),"AT_EID_EDITABLE").equals("1")&&!v.eid().equals(text(s.row(),"at_eid")))throw bad("Die Artikelnummer darf laut Niederlassungsvorgabe nicht geändert werden."); + if(!db.queryForList("SELECT at_id FROM article WHERE at_eid=? AND at_id<>?",v.eid(),c.id()).isEmpty())throw bad("Diese Artikelnummer ist bereits vergeben."); + if(!v.barcode().isEmpty()&&!db.queryForList("SELECT at_id FROM article WHERE at_barcode=? AND at_id<>?",v.barcode(),c.id()).isEmpty())throw bad("Dieser Barcode ist bereits vergeben."); + if(c.id()==0&&!db.queryForList("SELECT at_id FROM article WHERE at_name=?",v.name()).isEmpty())throw bad("Diese Artikelbezeichnung existiert bereits."); + if(s.links().size()>1)throw bad("Die Warengruppenzuordnung ist mehrdeutig. Bitte den Bestand klären."); + if(v.group()>0){var g=one(groups(u).stream().filter(x->id(x,"id")==v.group()).toList());evidence.add(g);if(!s.links().isEmpty()&&id(s.links().getFirst(),"hq_id")==0&&id(g,"headquarters")!=0)throw bad("Eine gemeinsame Zuordnung benötigt eine gemeinsame Warengruppe.");} + if(v.group()==0&&!s.links().isEmpty()&&id(s.links().getFirst(),"hq_id")==0)throw bad("Die gemeinsame Warengruppenzuordnung kann in dieser Maske nicht entfernt werden."); + if(c.id()>0&&v.serial()!=values(s).serial()){if(v.serial()&&(!s.bundles().isEmpty()||s.stock().stream().anyMatch(r->id(r,"stkat_itemquantity")>0)))throw bad("Seriennummern sind bei Lagerbestand oder Gebindezuordnung nicht einschaltbar.");if(!v.serial()&&!s.serials().isEmpty())throw bad("Es sind noch Seriennummern vorhanden.");} + }else if(Set.of("BUNDLE_ADD","BUNDLE_REMOVE").contains(Objects.toString(action,""))){ + if(c.id()==0)throw bad("Bitte den Artikel zuerst anlegen.");String eid=required(c.childEid(),20,"Bestandteil-ExtID");var child=one(db.queryForList("SELECT * FROM article WHERE at_eid=?",eid));long childId=id(child,"at_id");if(branches(user).stream().noneMatch(h->id(h,"id")==id(child,"hq_id")))throw MasterDirectoryScope.denied();if(childId==c.id())throw bad("Ein Artikel kann nicht sein eigener Bestandteil sein.");evidence.add(child); + var links=db.queryForList("SELECT * FROM articlebundle WHERE at_id=? AND at_id2=?",c.id(),childId);if(links.size()>1)throw bad("Mehrdeutiger Gebindebestandteil.");var descendants=descendants(childId);evidence.add(descendants); + if(action.equals("BUNDLE_REMOVE")){if(links.size()!=1)throw bad("Dieser Bestandteil ist nicht mehr zugeordnet.");if(!descendants.isEmpty())throw bad("Ein Bestandteil, der selbst ein Gebinde ist, kann wie in PHP nicht entfernt werden.");} + else {if(c.quantity()<1||c.quantity()>32767)throw bad("Die hinzuzufügende Menge muss zwischen 1 und 32767 liegen.");if(values(s).serial()||id(child,"at_serialno")==1)throw bad("Artikel mit Seriennummern können keinem Gebinde zugeordnet werden.");if(descendants.stream().anyMatch(b->id(b,"at_id2")==c.id()))throw bad("Die Zuordnung würde einen Gebindekreislauf erzeugen.");if(s.bundles().stream().noneMatch(b->id(b,"at_id")==c.id())&&s.stock().stream().anyMatch(r->id(r,"stkat_itemquantity")>0))throw bad("Ein Artikel mit Lagerbestand kann nicht in ein Gebinde umgewandelt werden.");} + long old=links.isEmpty()?0:id(links.getFirst(),"atb_bundlequantity"),next=action.equals("BUNDLE_REMOVE")?0:old+c.quantity();if(next>32767||old<0)throw bad("Die Gesamtmenge überschreitet 32767 oder der Bestand ist ungültig.");before=fields("child",childId,"eid",eid,"quantity",old);after=new LinkedHashMap<>(fields("child",childId,"eid",eid,"quantity",next)); + }else throw bad("Unbekannte Artikelaktion."); + 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;} + 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); + var links=db.queryForList("SELECT * FROM articlegroupitem WHERE at_id=? AND md_id=? AND hq_id IN (0,?)",article,mandator(db,u),u.headquartersId());if(v.group()==0)db.update("DELETE FROM articlegroupitem WHERE at_id=? AND md_id=? AND hq_id=?",article,mandator(db,u),u.headquartersId());else if(links.isEmpty())BusinessSupport.insertWithoutKey(db,"articlegroupitem",fields("at_id",article,"atg_id",v.group(),"md_id",mandator(db,u),"hq_id",u.headquartersId()));else db.update("UPDATE articlegroupitem SET atg_id=? WHERE at_id=? AND md_id=? AND hq_id=?",v.group(),article,mandator(db,u),id(links.getFirst(),"hq_id")); + }else {long child=id(p.after(),"child"),quantity=id(p.after(),"quantity");if(quantity==0)db.update("DELETE FROM articlebundle WHERE at_id=? AND at_id2=?",article,child);else if(id(p.before(),"quantity")==0)BusinessSupport.insertWithoutKey(db,"articlebundle",fields("at_id",article,"at_id2",child,"atb_bundlequantity",quantity));else db.update("UPDATE articlebundle SET atb_bundlequantity=? WHERE at_id=? AND at_id2=?",quantity,article,child);} + var details=p.changes().stream().map(r->fields("field",r.get("field"),"before",auditValue(r.get("before")),"after",auditValue(r.get("after")))).toList();if(c.id()==0||!p.action().equals("SAVE")||p.changes().stream().anyMatch(r->!Set.of("at_authenticated","at_visible").contains(text(r,"field"))))support.auditArticle(u,article,c.id()==0?61:62,"ARTICLE="+article+" | ACTION="+p.action()+" | CHANGES="+support.encode(details),c.requestId()); + if(c.id()>0&&p.action().equals("SAVE"))for(var flag:List.of("at_authenticated","at_visible"))if(p.changes().stream().anyMatch(r->text(r,"field").equals(flag)))support.auditArticle(u,article,flag.equals("at_authenticated")?63:64,(flag.equals("at_authenticated")?"STATUS_AUTHENTICATION=":"STATUS_VISIBILITY=")+p.after().get(flag),c.requestId());support.receipt(u,c.requestId(),intent,article);return fields("id",article,"repeated",false);});} + static Object auditValue(Object v){if(v instanceof String s&&s.length()>1000)return s.substring(0,1000)+" … [SHA256="+revision(s)+"]";return v;} + Map history(LegacyUser user,Long hq,long article,int offset){return db.readTransaction(()->{var u=scoped(user,hq);state(u,article);if(article<=0)throw bad("Artikel fehlt.");page(offset);var rows=db.queryForList("SELECT log_id AS id,DATE_FORMAT(log_createtime,'%Y-%m-%d %H:%i:%s') AS at,logo_id AS operation,usr_id AS actor,logo_description AS description FROM "+support.historySchema+".log WHERE at_id=? AND hq_id=? ORDER BY log_createtime DESC,log_id DESC LIMIT 51 OFFSET ?",article,u.headquartersId(),offset);var pending=new ArrayList();if(offset==0)for(var q:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngaudit' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC",u.headquartersId()))try{var e=support.json.readValue(text(q,"gdc_content"),BusinessSupport.Audit.class);if(e.article()==article&&rows.stream().noneMatch(r->text(r,"description").equals(e.description())))pending.add(fields("at",e.at(),"operation",e.operation(),"actor",e.user(),"description",e.description()));}catch(Exception ignored){}return fields("rows",rows.stream().limit(50).toList(),"pending",pending,"hasMore",rows.size()>50,"offset",offset);});} + static void page(int offset){if(offset<0||offset>100000||offset%50!=0)throw bad("Ungültige Seite.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java b/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java new file mode 100644 index 0000000..51bcae1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ArticleColumns.java @@ -0,0 +1,32 @@ +package de.stadtbote.votian; +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; + +/** PHP MASK_AT_LIST_COLS, stored with the existing employee list preferences. */ +@RestController +class ArticleColumns { + static final String KEY="MASK_AT_LIST_COLS"; + static final List KEYS=List.of("at_eid","at_name","at_serialno","at_mountable","at_match","at_barcode","at_createtime","at_id_history","at_id_edit","atg_data","at_description"); + static final List LABELS=List.of("ExtID","Bezeichnung","Seriennummern","Montierbar","Zusatz","Barcode","Angelegt","Historie","Bearbeiten","Warengruppe","Beschreibung"); + record Layout(List columns,String warning){} + record State(EmployeeListPreferences.State access,List> rows){} + final DatabaseService db;final BusinessSupport support; + ArticleColumns(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/account/article-columns") Object get(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId());} + @PutMapping("/api/account/article-columns") Object put(Authentication a,@RequestBody MasterListColumns.Command c){var u=ApiController.user(a);return save(u,u.employeeId(),c);} + @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 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()));} + static List> definitions(){return KEYS.stream().map(k->(Map)fields("key",k,"label",LABELS.get(KEYS.indexOf(k)))).toList();} + Map view(LegacyUser u,long employee){return db.readTransaction(()->{var s=state(u,employee);var l=layout(s.rows(),u.headquartersId());boolean own=u.headquartersId()==u.originalHeadquartersId(),valid=MasterListColumns.writable(s.rows(),s.access().mandator());return fields("employee",EmployeeAdministration.profile(s.access().person()),"headquartersId",u.headquartersId(),"columns",l.columns(),"definitions",definitions(),"defaults",KEYS,"max",KEYS.size(),"revision",version(s),"canSave",own&&valid,"warning",!own?"Persönliche Einstellungen können in der eigenen Niederlassung geändert werden.":valid?l.warning():"Die Artikellistenspalten sind nicht eindeutig zugeordnet.");});} + Map save(LegacyUser u,long employee,MasterListColumns.Command c){if(u.headquartersId()!=u.originalHeadquartersId())throw EmployeeAdministration.denied();if(c==null||c.columns()==null||c.columns().isEmpty()||c.columns().size()>KEYS.size()||c.columns().stream().anyMatch(k->k==null||!KEYS.contains(k)))throw bad("Bitte gültige Artikellistenspalten auswählen.");BusinessSupport.token(c.requestId());support.transactional("parameter","genericdatacontainer");return db.employeePreferencesWriteTransaction(()->{var s=state(u,employee);var intent=List.of("ARTICLE_COLUMNS",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);if(!MasterListColumns.writable(s.rows(),s.access().mandator()))throw bad("Die Artikellistenspalten sind nicht eindeutig zugeordnet.");if(!version(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Artikellistenspalten wurden zwischenzeitlich geändert. Bitte neu laden.");var old=s.rows().stream().filter(r->id(r,"emp_id")==employee&&id(r,"hq_id")==u.headquartersId()).toList();String value=String.join(",",c.columns());if(old.isEmpty())db.insert("parameter",fields("par_key",KEY,"par_value",value,"par_text","","emp_id",employee,"hq_id",u.headquartersId(),"md_id",s.access().mandator()));else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old.getFirst(),"par_id"));support.auditEmployee(u,employee,203,"ARTICLE_COLUMNS_BEFORE="+support.encode(layout(s.rows(),u.headquartersId()).columns())+" | ARTICLE_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/ArticleGroups.java b/backend/src/main/java/de/stadtbote/votian/ArticleGroups.java new file mode 100644 index 0000000..63bd956 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ArticleGroups.java @@ -0,0 +1,25 @@ +package de.stadtbote.votian; +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; + +/** PHP stock/atg_list.php: keyed groups, rename and branch/shared visibility. */ +@RestController +@RequestMapping("/api/administration/article-groups") +class ArticleGroups { + final ArticleAdministration articles;final DatabaseService db;final BusinessSupport support; + ArticleGroups(ArticleAdministration articles,DatabaseService db,BusinessSupport support){this.articles=articles;this.db=db;this.support=support;} + record Command(String requestId,long id,String revision,String code,String name,boolean shared){} + @GetMapping Object get(Authentication a,@RequestParam(required=false) Long headquarters){return list(ApiController.user(a),headquarters);} + @PostMapping Object post(Authentication a,@RequestParam(required=false) Long headquarters,@RequestBody Command c){return save(ApiController.user(a),headquarters,c);} + List> rows(LegacyUser u){return db.queryForList("SELECT * FROM articlegroup WHERE md_id=? AND hq_id IN (0,?) ORDER BY atg_key,atg_id",mandator(db,u),u.headquartersId());} + Map list(LegacyUser user,Long hq){if(user.warehouseAccount())throw MasterDirectoryScope.denied();return db.readTransaction(()->{var u=articles.scoped(user,hq);return fields("rows",rows(u).stream().map(r->fields("id",id(r,"atg_id"),"code",text(r,"atg_key"),"name",text(r,"atg_name"),"shared",id(r,"hq_id")==0,"revision",revision(r))).toList());});} + Map save(LegacyUser user,Long hq,Command c){if(user.warehouseAccount())throw MasterDirectoryScope.denied();if(c==null||c.id()<0)throw bad("Ungültige Warengruppe.");articles.scoped(user,hq);BusinessSupport.token(c.requestId());String code=required(ArticleAdministration.clean(c.code(),10,"Schlüssel"),10,"Schlüssel"),name=required(ArticleAdministration.clean(c.name(),100,"Bezeichnung"),100,"Bezeichnung");support.transactional("articlegroup","genericdatacontainer");return db.articleWriteTransaction(()->{var u=articles.scoped(user,hq);var intent=List.of("ARTICLE_GROUP",c);Long receipt=support.receipt(u,c.requestId(),intent);if(receipt!=null)return fields("id",receipt);long md=mandator(db,u);var before=c.id()==0?Map.of():one(rows(u).stream().filter(r->id(r,"atg_id")==c.id()).toList());if(c.id()>0){unchanged(before,c.revision());if(!code.equals(text(before,"atg_key")))throw bad("Der Schlüssel einer bestehenden Warengruppe bleibt unverändert.");} + if(!db.queryForList("SELECT atg_id FROM articlegroup WHERE md_id=? AND atg_key=? AND atg_id<>?",md,code,c.id()).isEmpty())throw bad("Dieser Warengruppenschlüssel ist bereits vergeben."); + if(!c.shared()&&c.id()>0&&id(before,"hq_id")==0&&!db.queryForList("SELECT at_id FROM articlegroupitem WHERE md_id=? AND atg_id=? AND (hq_id=0 OR hq_id<>?) LIMIT 1",md,c.id(),u.headquartersId()).isEmpty())throw bad("Die Warengruppe ist noch gemeinsam oder in anderen Niederlassungen zugeordnet."); + long id=c.id();var values=fields("atg_key",code,"atg_name",name,"hq_id",c.shared()?0:u.headquartersId());if(id==0){values.putAll(fields("md_id",md,"atg_group",""));id=db.insert("articlegroup",values);}else BusinessSupport.update(db,"articlegroup","atg_id",id,values);support.audit(u,0,0,0,203,"ARTICLE_GROUP="+id+" | BEFORE="+support.encode(before)+" | AFTER="+support.encode(values),c.requestId());support.receipt(u,c.requestId(),intent,id);return fields("id",id);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AssignmentOrders.java b/backend/src/main/java/de/stadtbote/votian/AssignmentOrders.java new file mode 100644 index 0000000..53f9176 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AssignmentOrders.java @@ -0,0 +1,40 @@ +package de.stadtbote.votian; + +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; + +/** Minimal dispatch context across headquarters; full order details keep their existing HQ boundary. */ +@RestController +@RequestMapping("/api/orders/{job}/courier-assignment/vehicles/{vehicle}/orders") +public class AssignmentOrders { + static final int PAGE_SIZE=20; + final DatabaseService db;final OrderOperations orders;final OrderEntryCatalog catalog; + public AssignmentOrders(DatabaseService db,OrderOperations orders,OrderEntryCatalog catalog){this.db=db;this.orders=orders;this.catalog=catalog;} + @GetMapping public Object get(Authentication auth,@PathVariable long job,@PathVariable long vehicle,@RequestParam long courierId,@RequestParam(defaultValue="1") int page){return list(ApiController.user(auth),job,vehicle,courierId,page);} + Map list(LegacyUser u,long job,long vehicle,long courier,int page){ + OrderCourierAssignment.access(u); + if(courier<=0||page<1||page>100000)throw bad("Bitte einen Fahrer und eine gültige Auftragsseite wählen."); + return db.readTransaction(()->{ + var s=orders.snapshot(u,job,false); + var v=AssignmentVehicles.resolve(db,catalog,u,vehicle,courier,true,false); + if(id(s.job(),"jb_longhaul")!=1&&id(v,"courier_hq")!=u.headquartersId()){ + var rows=ClassicCandidates.vehicles(db,catalog,u,vehicle); + var geo=ClassicCandidates.geography(db,catalog,u,s);var rules=ClassicCandidates.rules(db,catalog,u,s); + if(rows.size()!=1||!ClassicCandidates.visible(u,ClassicCandidates.candidate(u,s,rows.getFirst(),geo,rules,false),rules))throw bad("Dieses Fahrzeug ist in der aktuellen Vermittlung nicht sichtbar."); + } + String sid=text(v,"crvh_sid"); + String from=" FROM job j JOIN tour t ON t.jb_id=j.jb_id AND t.tr_sort=1 JOIN address a ON a.ad_id=t.ad_id LEFT JOIN serviceplz p ON p.srvp_plz=a.ad_zipcode LEFT JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id AND lt.hq_id=j.hq_id LEFT JOIN headquarters h ON h.hq_id=j.hq_id WHERE j.jb_id<>? AND j.cr_sid=? AND j.jb_status IN (0,1) AND COALESCE(j.jb_storno,0)=0"; + long total=db.queryForObject("SELECT COUNT(DISTINCT j.jb_id)"+from,Long.class,job,sid); + var rows=db.queryForList("SELECT j.jb_id,j.jb_ordertime,j.jb_status,j.jb_longhaul,j.hq_id,h.hq_name,MAX(CASE WHEN "+ClassicCandidates.OCCUPIED+" THEN 1 ELSE 0 END) AS occupied"+from+" GROUP BY j.jb_id,j.jb_ordertime,j.jb_status,j.jb_longhaul,j.hq_id,h.hq_name ORDER BY j.jb_id LIMIT ? OFFSET ?",dateTime(s.job(),"jb_ordertime"),dateTime(s.job(),"jb_ordertime"),job,sid,PAGE_SIZE,(page-1)*PAGE_SIZE); + var stops=new HashMap>>(); + if(!rows.isEmpty())for(var t:db.queryForList("SELECT t.jb_id,t.tr_id,t.tr_sort,t.tr_status,a.ad_zipcode FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id IN ("+String.join(",",Collections.nCopies(rows.size(),"?"))+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",rows.stream().map(r->id(r,"jb_id")).toArray())) + stops.computeIfAbsent(id(t,"jb_id"),k->new ArrayList<>()).add(fields("id",id(t,"tr_id"),"position",id(t,"tr_sort"),"postalCode",text(t,"ad_zipcode"),"completed",id(t,"tr_status")==1)); + return fields("sid",sid,"total",total,"page",page,"pageSize",PAGE_SIZE,"items",rows.stream().map(r->fields("id",id(r,"jb_id"),"at",text(r,"jb_ordertime"),"status",id(r,"jb_status"),"longhaul",id(r,"jb_longhaul")==1,"headquarters",text(r,"hq_name"),"canOpen",id(r,"hq_id")==u.headquartersId(),"occupied",id(r,"occupied")!=0,"stations",stops.getOrDefault(id(r,"jb_id"),List.of()))).toList()); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AssignmentVehicles.java b/backend/src/main/java/de/stadtbote/votian/AssignmentVehicles.java new file mode 100644 index 0000000..feffc8d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AssignmentVehicles.java @@ -0,0 +1,35 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Assignment identity is the logged-in courier, not necessarily the vehicle owner. */ +final class AssignmentVehicles { + static final String SETTING="RANKING_CR2CRVH_MULTI_RELATION"; + static final String IDENTITY="cv.cr_id AS owner_courier_id,cr.cr_id AS assignment_courier_id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS driver_name,(SELECT COUNT(*) FROM courier logged WHERE logged.cr_sid=cv.crvh_sid) AS logged_drivers"; + static boolean multiple(OrderEntryCatalog catalog,LegacyUser u){return "1".equals(LonghaulCandidates.setting(catalog,u,SETTING));} + static String from(boolean crossHeadquarters,boolean multiple){ + String from=OrderEntryCatalog.vehicleFrom(crossHeadquarters); + // An occupied login must not silently fall back to an offline owner, even if that login is blocked. + return multiple?from.replace("cr.cr_id=cv.cr_id","(cr.cr_sid=cv.crvh_sid OR (cr.cr_id=cv.cr_id AND NOT EXISTS(SELECT 1 FROM courier logged WHERE logged.cr_sid=cv.crvh_sid)))"):from; + } + static String joins(String from,String joins){int index=from.lastIndexOf("WHERE ");return from.substring(0,index)+joins+" "+from.substring(index);} + static Map identity(Map row){var result=new LinkedHashMap<>(row);result.put("cr_id",row.get("assignment_courier_id"));return result;} + static List> identities(List> rows){return rows.stream().map(AssignmentVehicles::identity).toList();} + static boolean ambiguous(Map row,boolean multiple){return multiple&&id(row,"logged_drivers")>1;} + static Map resolve(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,long vehicle,long expectedCourier,boolean crossHeadquarters,boolean lock){ + if(vehicle<=0||expectedCourier<0)throw bad("Bitte ein gültiges Fahrzeug und einen Fahrer wählen."); + boolean multiple=multiple(catalog,u); + String sql="SELECT cv.crvh_id,cv.crvh_sid,cv.vht_id,cr.cr_sid AS current_sid,cr.hq_id AS courier_hq,"+IDENTITY+" "+from(crossHeadquarters,multiple)+" AND cv.crvh_id=?"; + Object[] args=crossHeadquarters?new Object[]{vehicle}:new Object[]{u.headquartersId(),vehicle}; + var rows=lock?db.lockRows(sql,args):db.queryForList(sql,args); + if(rows.size()!=1||ambiguous(rows.getFirst(),multiple))throw bad("Fahrzeug/Fahrer ist nicht verfügbar, nicht eindeutig angemeldet oder gehört zu einer anderen Niederlassung. Bitte die Anmeldung prüfen."); + var result=new LinkedHashMap<>(identity(rows.getFirst())); + if(expectedCourier>0&&id(result,"cr_id")!=expectedCourier)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Der angemeldete Fahrer hat sich geändert. Bitte die Auswahl aktualisieren."); + if(expectedCourier==0&&id(result,"cr_id")!=id(result,"owner_courier_id"))throw bad("Bitte die Fahrerauswahl aktualisieren und den angemeldeten Fahrer ausdrücklich wählen."); + result.put("multiple_drivers_enabled",multiple);return result; + } + private AssignmentVehicles(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticCompletionMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticCompletionMail.java new file mode 100644 index 0000000..96c8307 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticCompletionMail.java @@ -0,0 +1,91 @@ +package de.stadtbote.votian; + +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.OrderCompletionProblems.*; + +/** Operational get_job_distance alerts, with a durable claim before any SMTP handoff. */ +class AutomaticCompletionMail { + record Decision(Kind kind,Problem problem,String state,String reason,List recipients,String revision) { + String key(){return problem==null?"COMPLETION_"+kind:problem.key();} + } + record Prepared(Decision decision,OrderEntryRequestMail.Envelope envelope,String subject,String html,String revision) {} + final AutomaticOrderMail service; + AutomaticCompletionMail(AutomaticOrderMail service){this.service=service;} + static String label(Kind kind){return kind==Kind.COURIER?"Abschlussprüfung: Kurier fehlt":"Auftragsprüfung: Stationsadresse";} + List> stops(long job){return service.db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job);} + boolean reportRequired(long payer){ + var rows=service.db.queryForList("SELECT cs.cs_id_parent,cmp.cmp_sustainability_email FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id=?",payer); + if(rows.size()>1)throw bad("Der Nachhaltigkeitskunde ist nicht eindeutig.");if(rows.isEmpty())return false; + var row=rows.getFirst();if(!text(row,"cmp_sustainability_email").isBlank())return true; + if(id(row,"cs_id_parent")<=0)return false; + var parents=service.db.queryForList("SELECT cmp.cmp_sustainability_email FROM customer parent JOIN company cmp ON cmp.cmp_id=parent.cmp_id WHERE parent.cs_id=?",id(row,"cs_id_parent")); + if(parents.size()>1)throw bad("Der übergeordnete Nachhaltigkeitskunde ist nicht eindeutig.");return parents.stream().anyMatch(p->!text(p,"cmp_sustainability_email").isBlank()); + } + Decision decide(Map job,Kind kind){ + long order=id(job,"jb_id"),hq=id(job,"hq_id");Problem problem=null;String state="DISABLED",reason="Kein entsprechender Abschlussfehler vorgemerkt.";var source=new ArrayList();source.add(job); + var rows=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,OrderCompletionProcessing.RESULT);source.add(rows); + if(rows.size()>1)throw bad("Die Abschlussfehlermeldung ist mehrdeutig."); + if(rows.size()==1&&text(rows.getFirst(),"gdc_context").equals("FAILED"))try{ + var node=service.support.json.readTree(text(rows.getFirst(),"gdc_content")).path("problem"); + if(!node.isMissingNode()&&!node.isNull()){var parsed=service.support.json.treeToValue(node,Problem.class);if(parsed.kind()==null||parsed.basis()==null||!parsed.basis().matches("[a-f0-9]{64}")||parsed.station()<0)throw new IllegalArgumentException();if(parsed.kind()==kind)problem=parsed;} + }catch(Exception e){throw bad("Die Abschlussfehlermeldung ist unlesbar.");} + List to=List.of(); + if(problem!=null){ + var stops=stops(order);source.add(stops); + if(!eligible(job)||!service.db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",order).isEmpty()){state="EXCLUDED";reason="Auftragsstatus oder Abrechnung erlaubt keine Abschlussfehlermail.";} + else if(!problem.basis().equals(basis(job,stops))){state="WAITING";reason="Der Auftrag wurde seit dem Fehler geändert. Eine neue Abschlussprüfung steht aus.";} + else if(service.completion==null||!service.completion.active(hq)){reason="Die native Abschlussaufbereitung ist für diese Niederlassung ausgeschaltet.";} + else if(!service.db.queryForList("SELECT sp_id FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname='get_job_distance' LIMIT 1",order).isEmpty()){state="WAITING";reason="Die PHP-Abschlussaufbereitung arbeitet noch an diesem Auftrag.";} + else if(service.completion.pending(order,false).isEmpty()&&service.completion.queue(order,false).isEmpty()){state="DONE";reason="Es besteht keine offene Abschlussvormerkung mehr.";} + else if(kind==Kind.COURIER&&(id(job,"jb_status")!=2||id(job,"cr_id")>0)||kind==Kind.ADDRESS&&(problem.station()<1||problem.station()>stops.size())){state="EXCLUDED";reason="Der gespeicherte Fehler passt nicht zum aktuellen Auftrag.";} + else if(kind==Kind.ADDRESS&&!reportRequired(id(job,"csc_id_payer"))){reason="Für Zahler und Elternkunde ist kein Nachhaltigkeitsreport eingerichtet.";} + else{ + var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy); + to=OrderEntryMail.addresses(renderer.local("MAIL_SURVEY_ADDRESS_TO")); + if(to.isEmpty())reason="Für Abschlussfehler fehlt MAIL_SURVEY_ADDRESS_TO in dieser Niederlassung."; + else{state="READY";reason="Ein bestätigter Abschlussfehler wartet auf die Betriebsbenachrichtigung.";} + } + } + return new Decision(kind,problem,state,reason,to,revision(List.of(source,Objects.toString(problem,""),state,to))); + } + List> view(Map job){ + if(service.completion==null)return List.of();var out=new ArrayList>();long hq=id(job,"hq_id"),order=id(job,"jb_id"); + for(var kind:Kind.values())try{ + var decision=decide(job,kind);var receipt=decision.problem()==null?null:service.receipt(hq,order,decision.key()); + if(receipt==null&&decision.problem()==null){var previous=service.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!' ORDER BY gdc_id DESC LIMIT 1",order,"COMPLETION!_"+kind+"!_%");if(!previous.isEmpty())receipt=service.decode(previous.getFirst(),hq,order);} + out.add(fields("key",decision.key(),"event",label(kind),"recipientLabel","Betriebsüberwachung","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())); + }catch(RuntimeException e){out.add(fields("key","COMPLETION_"+kind,"event",label(kind),"recipientLabel","Betriebsüberwachung","additional",false,"state","BLOCKED","reason",AutomaticOrderMail.safe(e),"recipients",List.of(),"startedAt","","completedAt",""));} + return out; + } + Prepared prepare(long hq,long order,Kind kind){ + var decision=decide(service.job(hq,order),kind);if(!decision.state().equals("READY"))return null; + var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);String from=renderer.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from=service.transport.from; + // Use the reviewed operational recipient setting. Never inherit customer mail copies or PHP's hardcoded developer address. + var envelope=new OrderEntryRequestMail.Envelope(AppointmentNotifications.address(from),decision.recipients(),List.of(),List.of()); + String subject=kind==Kind.COURIER?"Kurier-ID in Auftrag "+order+" fehlt":"CO₂e-Ermittlung in Auftrag "+order+": Adresse in Station "+decision.problem().station()+" prüfen"; + String body=kind==Kind.COURIER?"Auftrag "+order+" hat keine Kurier-ID. Die Abschlussaufbereitung ist fehlgeschlagen; der Auftrag ist zur Nachbearbeitung gekennzeichnet. Bitte den ausführenden Kurier korrigieren.":"Für Auftrag "+order+" ist ein Nachhaltigkeitsreport erforderlich. Die Adresse in Station "+decision.problem().station()+" konnte nicht eindeutig ermittelt werden. Bitte die Stationsadresse korrigieren und die Abschlussaufbereitung erneut prüfen."; + String html="

"+OrderCustomerMailContent.escape(body)+"

";return new Prepared(decision,envelope,subject,html,revision(List.of(decision.revision(),envelope,subject,html))); + } + String process(long hq,long order,Kind kind){ + if(service.completion==null||!service.completion.active(hq))return "SKIPPED";service.available(hq); + var prepared=service.db.readTransaction(()->prepare(hq,order,kind));if(prepared==null)return "SKIPPED"; + String key=prepared.decision().key();if(service.receipt(hq,order,key)!=null)return "ALREADY_STARTED";service.support.transactional("genericdatacontainer");String token=UUID.randomUUID().toString(); + var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{ + service.available(hq);if(!service.completion.active(hq))throw bad("Die Abschlussaufbereitung ist ausgeschaltet.");if(service.receipt(hq,order,key)!=null)return null; + var current=prepare(hq,order,kind);if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Abschlussfehlermail wurde durch eine Änderung überholt."); + var d=new AutomaticOrderMail.Delivery(token,hq,order,key,label(kind),false,"STARTED",service.now().toString(),"",prepared.subject(),prepared.envelope(),prepared.revision()); + service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",service.support.encode(d)));service.support.audit(service.actor(hq),order,0,0,203,label(kind)+": Übergabe vorgemerkt",token);return d; + });if(claim==null)return "ALREADY_STARTED"; + String state="SENT";try{var e=claim.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.subject(),prepared.html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";} + finish(claim,state);return state; + } + void finish(AutomaticOrderMail.Delivery claim,String state){service.db.writeTransaction(()->{ + var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order()); + if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Abschlussfehlermail-Beleg wurde verändert."); + var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision()); + service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,claim.event()+": "+AutomaticOrderMail.message(state),claim.token()+":result");return null; + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java new file mode 100644 index 0000000..8d30691 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMail.java @@ -0,0 +1,63 @@ +package de.stadtbote.votian; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** PHP automailer6/8: overdue mediation and its bounded success notification. */ +class AutomaticMediationMail { + enum Event { + JAM("Vermittlungsstau","jb_job_jam","automailer6"),RECOVERED("Vermittlung nach Stau","jb_job_jam_ctrl","automailer8"); + final String label,field,semaphore;Event(String label,String field,String semaphore){this.label=label;this.field=field;this.semaphore=semaphore;}String key(){return "MEDIATION_"+name();} + } + record Decision(Event event,String state,String reason,List recipients,long marker,long customer,String revision) {} + record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,int minutes,String revision) {} + final AutomaticOrderMail service; + AutomaticMediationMail(AutomaticOrderMail service){this.service=service;} + List> marker(long order,String field){var rows=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,field);if(rows.size()>1)throw bad("Die Vermittlungsmail-Vormerkung ist mehrdeutig.");return rows;} + static LocalDateTime deadline(String raw){try{return LocalDateTime.parse(raw,DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT));}catch(DateTimeException e){throw bad("Die Vermittlungsmail-Frist ist ungültig.");}} + static int minutes(String raw){if(raw.isBlank())return 10;if(!raw.matches("[0-9]{1,5}"))throw bad("Die Nachfrist für Vermittlungsmails ist ungültig.");return Integer.parseInt(raw);} + static boolean window(Map job,Event event,LocalDateTime due,LocalDateTime now){ + var at=dateTime(job,"jb_ordertime");if(at==null||!at.isAfter(now.toLocalDate().atStartOfDay())||id(job,"jb_storno")!=0)return false; + if(event==Event.RECOVERED)return id(job,"jb_status")==1&&!now.isAfter(due); + return !Set.of(1L,2L).contains(id(job,"jb_status"))&&id(job,"jb_offer")==0&&id(job,"jb_automailsent")==0&&id(job,"jb_automailsent2")==0&&!due.isAfter(now); + } + static boolean arrived(String raw){if(raw.length()<14)return false;try{LocalDateTime.parse(raw.substring(0,14),DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT));return true;}catch(DateTimeException e){return false;}} + Decision decide(Map job,Event event,LocalDateTime now){ + long order=id(job,"jb_id"),marker=0,customer=0;String state="DISABLED",reason="Keine Vermittlungsmail-Vormerkung vorhanden.";var sources=new ArrayList();var recipients=new ArrayList();var rows=marker(order,event.field);sources.add(rows); + if(!rows.isEmpty()&&!text(rows.getFirst(),"gdc_content").isBlank()){ + var row=rows.getFirst();marker=id(row,"gdc_id");String context=text(row,"gdc_context"); + if(row.get("gdc_context")==null||Set.of("MAIL_SENT","MAIL_NOT_SENT").contains(context)){state="DONE";reason="Die vorhandene Vermittlungsmail-Kennzeichnung lässt keinen Versand zu.";} + else if(!window(job,event,deadline(text(row,"gdc_content")),now)){state="WAITING";reason="Auftragsstatus oder Vermittlungsfrist passt noch nicht beziehungsweise nicht mehr.";} + else{ + state="READY";reason="Die PHP-Bedingungen für die Vermittlungsmail sind erfüllt."; + if(event==Event.RECOVERED){var jam=marker(order,Event.JAM.field);sources.add(jam);if(jam.size()!=1||!text(jam.getFirst(),"gdc_context").equals("MAIL_SENT")){state="WAITING";reason="Eine erfolgreich übergebene Stau-Mail fehlt.";}} + else{var stops=service.db.queryForList("SELECT t.tr_id,t.tr_status FROM tour t WHERE t.jb_id=? AND t.tr_sort=1",order);sources.add(stops);if(stops.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig.");var stop=stops.getFirst();var arrivals=service.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='arr_tour'",id(stop,"tr_id"));sources.add(arrivals);if(arrivals.size()>1)throw bad("Die Ankunftszeit ist mehrdeutig.");if(id(stop,"tr_status")==1||!arrivals.isEmpty()&&arrived(text(arrivals.getFirst(),"gdc_content"))){state="WAITING";reason="Die erste Station wurde bereits erreicht oder erledigt.";}} + if(state.equals("READY")){var customers=new AutomaticOrderMailPolicy(service.db).customer(id(job,"csc_id_payer"),id(job,"hq_id"));sources.add(customers);if(customers.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig.");if(customers.isEmpty()||id(customers.getFirst(),"cs_jb_jam_waittime")<=0){state="DISABLED";reason="Beim Kunden sind keine Vermittlungsstau-Mails eingeschaltet.";}else{var c=customers.getFirst();customer=id(c,"cs_id");long selection=id(c,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");recipients.addAll(OrderEntryMail.addresses(text(c,selection==1?"cscad_email":"usr_email").replace(" ","")));if(recipients.isEmpty()){state="DISABLED";reason="Die ausgewählte Kundenadresse ist leer.";}}} + } + } + 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;} + 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."); + if(event==Event.JAM&&!marker(order,Event.RECOVERED.field).isEmpty())throw bad("Eine Nachfrist existiert bereits. Bitte den Vermittlungsmail-Bestand prüfen."); + var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);int minutes=event==Event.JAM?minutes(renderer.fallback("CS_JB_JAM_WAITTIME_CTRL")):0;String from=renderer.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from="support@assecutor.de"; + // sendExternalMailExtended is called without copies; configured order Cc/Bcc do not apply here. + var envelope=new OrderEntryRequestMail.Envelope(AppointmentNotifications.address(from),decision.recipients(),List.of(),List.of());var content=AutomaticMediationMailContent.build(renderer,job,decision.customer(),event==Event.RECOVERED); + return new Prepared(decision,content,envelope,minutes,revision(List.of(decision.revision(),content.revision(),envelope,minutes))); + } + String process(long hq,long order,Event event){service.available(hq);if(service.receipt(hq,order,event.key())!=null)return "ALREADY_STARTED";var time=service.now();String token=UUID.randomUUID().toString();var prepared=service.db.readTransaction(()->prepare(hq,order,event,time));if(prepared==null)return "SKIPPED";service.support.transactional("genericdatacontainer"); + var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{service.available(hq);if(service.receipt(hq,order,event.key())!=null)return null;var current=prepare(hq,order,event,service.now());if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Vermittlungsmaildaten wurden geändert."); + var d=new AutomaticOrderMail.Delivery(token,hq,order,event.key(),event.label,false,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision());service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",event.key(),"gdc_context","STARTED","gdc_content",service.support.encode(d)));service.db.update("UPDATE genericdatacontainer SET gdc_context='MAIL_NOT_SENT' WHERE gdc_id=?",current.decision().marker());service.support.audit(service.actor(hq),order,0,0,203,event.label+": automatische Mail zur Übergabe vorgemerkt",token);return d;});if(claim==null)return "ALREADY_STARTED"; + String state="SENT";try{var e=prepared.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";}finish(claim,prepared,state);return state; + } + void finish(AutomaticOrderMail.Delivery claim,Prepared prepared,String state){service.db.writeTransaction(()->{ + var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Vermittlungsmailbeleg wurde verändert.");var now=service.now(); + if(state.equals("SENT")){if(service.db.update("UPDATE genericdatacontainer SET gdc_context='MAIL_SENT' WHERE gdc_id=? AND gdc_context='MAIL_NOT_SENT'",prepared.decision().marker())!=1)throw bad("Die Vermittlungsmail-Kennzeichnung wurde verändert.");if(prepared.decision().event()==Event.JAM){var prior=service.db.lockRows("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_job_jam_ctrl'",claim.order());if(!prior.isEmpty())throw bad("Die Vermittlungsnachfrist wurde inzwischen angelegt.");service.db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",claim.order(),"gdc_gen_fieldname","jb_job_jam_ctrl","gdc_content",now.plusMinutes(prepared.minutes()).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),"gdc_context",""));}} + var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),now.toString(),claim.subject(),claim.envelope(),claim.revision());service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id"));service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,claim.event()+": "+AutomaticOrderMail.message(state),claim.token()+":result");return null; + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java new file mode 100644 index 0000000..2e87256 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticMediationMailContent.java @@ -0,0 +1,34 @@ +package de.stadtbote.votian; +import java.time.format.DateTimeFormatter; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.OrderCustomerMailContent.*; + +/** Text and common wrapper of automailer6/8 and sendExternalMailExtended. */ +final class AutomaticMediationMailContent { + static Content build(OrderCustomerMailContent c,Map job,long customer,boolean recovered){ + String language=c.parameter(0,"JOBDETAILS_EMAIL_LANGUAGE_"+customer),global=c.parameter(0,"SYSTEM_LANGUAGE_DEFAULT");if(language.isBlank())language="0";if(global.isBlank())global="0"; + c.language=OrderMailLanguage.load(c.db,language,false,language.equals(global)?"include/mcglobal.inc.php":"include/automailer"+(recovered?8:6)+".php"); + String at=dateTime(job,"jb_ordertime").format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")); + String subject=recovered?c.t("Ihr Auftrag")+" "+id(job,"jb_id")+" "+c.t("wurde erfolgreich vermittelt"):c.t("Verzögerung bei Ihrem Auftrag Nr.: ")+" "+id(job,"jb_id"); + var body=new StringBuilder("
").append(rich(c.mailText("MAIL_PRE_SALUTATION_TEXT","Sehr geehrte Damen und Herren,",false))).append("

"); + if(recovered){body.append(escape(c.t("wir möchten Sie darüber informieren, dass Ihr aktueller Auftrag, geplant für"))).append(' ').append(escape(at)).append(", ").append(escape(c.t("nun erfolgreich an einen Boten vermittelt wurde."))).append("
") + .append(escape(c.t("Unser Fahrer befindet sich bereits auf dem Weg zu Ihnen und wird die Lieferung schnellstmöglich zustellen."))).append("
").append(escape(c.t("Sollten Sie weitere Fragen haben, stehen wir Ihnen gerne zur Verfügung."))).append("

").append(escape(c.t("Wir danken Ihnen für Ihr Vertrauen in STADTBOTE und wünschen Ihnen einen angenehmen Tag.")));} + else{body.append(escape(c.t("wir möchten Sie darüber informieren, dass es bei Ihrem aktuellen Auftrag geplant für"))).append(' ').append(escape(at)).append(' ').append(escape(c.t("zu einer Verzögerung in der Vermittlung kommt."))).append("
") + .append(escape(c.t("Unser Team arbeitet aktiv daran, einen verfügbaren Boten für Ihren Auftrag zu finden. Wir prüfen alle Optionen, um Ihre Lieferung schnellstmöglich zu vermitteln."))).append("
").append(escape(c.t("Sobald Ihr Auftrag erfolgreich vermittelt wurde, werden wir Sie per Mail informieren."))).append("

").append(escape(c.t("Wir hoffen auf Ihr Verständnis und bedanken uns für Ihr Vertrauen in STADTBOTE.")));} + body.append("

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

").append(rich(c.t(c.local("MAIL_SALUTATION_TEXT")))).append("
"); + var warnings=new ArrayList();String logo=c.local("IMG_LOGO_NAME"),height=c.local("IMG_LOGO_HEIGHT"),width=c.local("IMG_LOGO_WIDTH"); + 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."); + } + 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("
"); + for(int i=1;i<=5;i++){String value=c.fallback("MAIL_FOOTER_RESPONSIBILITY"+(i==1?"":"_"+i));if(value.isBlank())break;body.append("

").append(rich(value)).append("

");}body.append("

").append(rich(c.local("MAIL_FOOTER_THINK_GREEN"))).append("

"); + } + String html=""+body+""; + if(html.length()>400000)throw bad("Die Vermittlungsmail ist zu umfangreich.");warnings.addAll(c.language.warnings());return new Content(subject,html,List.copyOf(warnings),revision(List.of(job,customer,recovered,c.parameters,html,subject,c.language.revision)),false); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java new file mode 100644 index 0000000..0c1aa2d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOfferPolicy.java @@ -0,0 +1,59 @@ +package de.stadtbote.votian; + +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.*; + +/** Rules of the current PHP autoranking_single.php, independent of scheduling and persistence. */ +final class AutomaticOfferPolicy { + record Settings(Map local,Map global){ + String local(String key){return local.getOrDefault(key,"").trim();} + String inherited(String key){String v=local(key);return v.isEmpty()?global.getOrDefault(key,"").trim():v;} + int number(String key,int fallback,int max){String v=local(key);if(v.isEmpty())return fallback;if(!v.matches("[0-9]{1,6}"))throw bad(key+" ist ungültig.");int n=Integer.parseInt(v);if(n>max)throw bad(key+" überschreitet "+max+".");return n;} + } + 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);} + } + static String commonBlock(Map j,Settings s,LocalDateTime now){ + if(id(j,"jb_status")!=8)return "Der Auftrag steht nicht im Autoranking."; + String guard=OrderRankingBatch.blocked(j,now);if(!guard.isEmpty())return guard; + if(id(j,"jb_longhaul")==1)return "Fernaufträge verwenden die BWV-Vermittlung."; + if(id(j,"jb_globaljob")==1)return "Der Auftrag wird bereits allen Kurieren angeboten."; + if(id(j,"hq_id_dispo")<=0)return "Die Dispositionsniederlassung fehlt."; + if(!s.local("AUTORANKING_ASSIGNMENT_ENABLED").equals("1"))return "Automatische Angebote sind für die Dispositionsniederlassung ausgeschaltet."; + return ""; + } + static List blockers(Map j,Map v,Settings s,int group,int challenges,int historyCount,boolean occupied,boolean quarantine,Set countries,List> filters,boolean blocked){ + var b=new ArrayList();boolean reserved=group==0,fav=group==1; + if(text(v,"cr_sid").isBlank()||id(v,"vehicle_count")!=1||id(v,"logged_drivers")!=1)b.add("Fahrer und angemeldetes Fahrzeug sind nicht eindeutig"); + if(id(v,"cr_app_blocked")==1||id(v,"cmp_visible")!=1||id(v,"cmp_archived")!=0||id(v,"driver_user_id")<=0||id(v,"driver_user_hq")!=id(v,"hq_id"))b.add("Fahrerzugang ist gesperrt"); + if(!reserved&&id(v,"cr_available")!=1)b.add("Nicht verfügbar gemeldet"); + if(!reserved&&!fav&&id(v,"cmp_authenticated")!=1)b.add("Unternehmen ist nicht freigegeben"); + if(!reserved&&id(v,"vht_id")id(v,pair[1]))b.add(pair[2]+" nicht ausreichend"); + if(!reserved&&(!fav||s.inherited("RANKING_FAVOURED_COURIER_FILTER").equals("1")))for(var filter:filters){String f=text(filter,"crf_short");long type=id(filter,"crf_type");if((type==0||type==1)&&FleetAssignment.tokens(text(j,"jb_cr_filter")).contains(f)&&!FleetAssignment.tokens(text(v,type==0?"crvh_filter":"cr_filter")).contains(f))b.add("Filterbedingung fehlt: "+f);} + int threshold=s.number("AUTORANKING_VEHICLE_TYPE_LEVEL_BEING_LKW",7,999),minimum=s.number("AUTORANKING_VEHICLE_LKW",5,999);boolean exception=false; + for(String rule:s.local("AUTORANKING_CR_VHT_GETS_JB_VHT").split("\\|")){String[] parts=rule.split("=",2);if(parts.length==2&&parts[0].trim().equals(Long.toString(id(v,"vht_id")))&&FleetAssignment.tokens(parts[1]).contains(Long.toString(id(j,"vht_id"))))exception=true;} + if(!exception&&id(v,"vht_id")>=threshold&&id(j,"vht_id")=max)b.add("Versuchslimit erreicht"); + if(historyCount>1||challenges<0)b.add("Rankinghistorie ist nicht eindeutig"); + if(id(v,"cr_ar_jb_id")!=0)b.add("Ein anderes automatisches Angebot ist noch offen"); + if(occupied)b.add("Bereits durch einen zeitlich überlappenden Auftrag belegt"); + if(quarantine)b.add("Quarantänezuordnung schließt diesen Kunden aus"); + String base=s.global().getOrDefault("GLOBAL_NATIONALITY_DEFAULT","DE");if(base.isBlank())base="DE";String nationality=ClassicCandidates.country(text(v,"usr_country")); + if(!nationality.isBlank()&&!nationality.equals(base))for(String rule:s.global().getOrDefault("GLOBAL_NATIONALITY_WHITELIST","").split("\\|")){var parts=rule.split("=",2);if(parts.length==2&&countries.contains(parts[0].trim())&&!parts[0].trim().equals(base)&&!parts[0].trim().equals(nationality)&&!FleetAssignment.tokens(parts[1]).contains(nationality))b.add("Länderfreigabe ist nicht erfüllt");} + String eu=s.global().getOrDefault("EU_COUNTRYCODES","");if(id(v,"crvh_totalweight")>=2800&&!FleetAssignment.tokens(text(v,"crvh_filter")).contains("EG")&&countries.stream().anyMatch(c->!c.isBlank()&&!c.equals("DE")&&eu.contains(c)))b.add("EG-Lizenz für Auslandstour fehlt"); + return List.copyOf(b); + } + static boolean locating(Settings s,LocalDateTime now){ + if(!s.inherited("LOCATING_PDA_ENABLED").equals("1"))return false;String interval=s.inherited("LOCATING_PDA_INTERVAL");if(interval.isEmpty())return true; + try{var p=interval.split(",");if(p.length!=4)return false;var start=LocalTime.of(Integer.parseInt(p[0].trim()),Integer.parseInt(p[1].trim()));var end=LocalTime.of(Integer.parseInt(p[2].trim()),Integer.parseInt(p[3].trim()));return !now.toLocalTime().withSecond(0).withNano(0).isBefore(start)&&!now.toLocalTime().withSecond(0).withNano(0).isAfter(end);}catch(RuntimeException e){return false;} + } + private AutomaticOfferPolicy(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java new file mode 100644 index 0000000..6b2b488 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOffers.java @@ -0,0 +1,126 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +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; +import static de.stadtbote.votian.AutomaticOfferPolicy.*; + +/** Native automatic offer rounds. Each round re-reads PHP data under a short table lock. */ +@RestController +@RequestMapping("/api/orders/{job}/mediation/automatic") +public class AutomaticOffers { + final DatabaseService db;final BusinessSupport support;final boolean enabled; + private LocalDateTime cursorTime=LocalDateTime.of(1970,1,1,0,0);private long cursorId; + volatile Run lastRun=new Run(null,0,0,0,0); + record Run(LocalDateTime at,int checked,int offered,int manualOrGlobal,int failed){} + record State(Map job,Settings settings,Plan plan){} + enum Result {STOP,NEXT,OFFERED,FALLBACK} + public AutomaticOffers(DatabaseService db,BusinessSupport support,@Value("${app.mediation.assignment-enabled:false}") boolean enabled){this.db=db;this.support=support;this.enabled=enabled;} + @GetMapping public Object get(Authentication auth,@PathVariable long job){return view(ApiController.user(auth),job);} + Map view(LegacyUser user,long job){OrderCourierAssignment.access(user);return db.readTransaction(()->{if(db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",job,user.headquartersId()).isEmpty())throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"Auftrag nicht vorhanden oder nicht freigegeben.");var state=state(job);if(state==null||id(state.job(),"hq_id")!=user.headquartersId())throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"Auftrag nicht vorhanden oder nicht freigegeben.");return fields("workerEnabled",enabled,"plan",state.plan());});} + LocalDateTime now(){return db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime();} + Settings settings(long hq){ + var local=new TreeMap();var global=new TreeMap(); + for(var p:db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND (par_key LIKE 'AUTORANKING_%' OR par_key LIKE 'RANKING_%' OR par_key IN ('MODE_INTERMEDIATION','CSC_ID_PAYER_CASH','LOCATING_PDA_ENABLED','LOCATING_PDA_INTERVAL','GLOBAL_NATIONALITY_DEFAULT','GLOBAL_NATIONALITY_WHITELIST','EU_COUNTRYCODES'))",hq)){ + var target=id(p,"hq_id")==0?global:local;if(target.put(text(p,"par_key"),text(p,"par_value"))!=null)throw bad("Mehrdeutige Vermittlungseinstellung: "+text(p,"par_key")); + }return new Settings(local,global); + } + State reason(Map j,Settings s,String state,String message,LocalDateTime starts){return new State(j,s,new Plan(state,message,starts,0,false,false,false,List.of()));} + State state(long job){ + var rows=db.queryForList("SELECT job.*,EXISTS(SELECT 1 FROM invoice i WHERE i.jb_id=job.jb_id) AS ng_invoiced,EXISTS(SELECT 1 FROM job credit WHERE credit.jb_id_copy=job.jb_id AND credit.jb_storno IN (3,4)) AS ng_credited,EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=job.jb_id AND g.gdc_gen_fieldname='ng_invoice_lock') AS ng_invoice_lock FROM job WHERE job.jb_id=?",job); + if(rows.isEmpty())return null;var j=rows.getFirst();var s=settings(id(j,"hq_id_dispo"));var time=now();String common=commonBlock(j,s,time); + if(!common.isEmpty())return reason(j,s,id(j,"jb_status")!=8?"NONE":!s.local("AUTORANKING_ASSIGNMENT_ENABLED").equals("1")?"DISABLED":"BLOCKED",common,null); + var stops=db.queryForList("SELECT t.tr_sort,t.csc_id,a.ad_zipcode,a.ad_country FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job); + if(stops.isEmpty()||id(stops.getFirst(),"tr_sort")!=1||stops.stream().filter(t->id(t,"tr_sort")==1).count()!=1||text(stops.getFirst(),"ad_zipcode").isBlank())return reason(j,s,"BLOCKED","Die erste Abholstation ist nicht eindeutig oder ohne PLZ.",null); + LocalDateTime starts=null; + if(id(j,"jb_reserv")==1&&id(j,"jb_globaljob")!=2){ + var travel=db.queryForList("SELECT lt.srvpt_traveltime FROM serviceplz p JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id WHERE p.srvp_plz=? AND lt.hq_id=?",text(stops.getFirst(),"ad_zipcode"),id(j,"hq_id_dispo")); + if(travel.size()!=1||travel.getFirst().get("srvpt_traveltime")==null||id(travel.getFirst(),"srvpt_traveltime")<0||dateTime(j,"jb_ordertime")==null)return reason(j,s,"BLOCKED","Für die Reservierung fehlt eine eindeutige Anfahrtszeit oder Auftragszeit.",null); + starts=dateTime(j,"jb_ordertime").minusMinutes(Math.max(30,id(travel.getFirst(),"srvpt_traveltime")));if(time.isBefore(starts))return reason(j,s,"WAITING","Die Reservierung wird erst im vorgesehenen Anfahrtsfenster angeboten.",starts); + } + if(s.number("AUTORANKING_MAXNUMBER_OF_CHALLENGES",0,32767)<=0)return reason(j,s,"BLOCKED","Ein positives Versuchslimit muss konfiguriert sein.",starts); + int iterations=s.number("AUTORANKING_NUMBER_OF_ITERATIONS",2,100);if(iterations==0)iterations=2; + boolean reserved=(id(j,"jb_reserv")==1||id(j,"jb_permanent")==1)&&id(j,"cr_id_order")>0; + boolean ignored=!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_ignore_fav_only' AND gdc_content='1'",job).isEmpty(); + var station=customers(stops.stream().map(t->id(t,"csc_id")).toList()); + long payer=id(j,"csc_id_payer");String cash=s.local("CSC_ID_PAYER_CASH");boolean cashPayer=payer==-1||!cash.isBlank()&&cash.equals(Long.toString(payer)); + var servicePayers=customers(db.queryForList("SELECT csc_id FROM tourservice WHERE jb_id=?",job).stream().map(t->id(t,"csc_id")).toList()); + var payers=cashPayer?customers(List.of(id(j,"csc_id_payer_cash"))):servicePayers; + // PHP's payer switch supersedes the station-only query for this particular policy. + var onlyRows=s.local("RANKING_FAVOURED_COURIER_FOR_PAYER").equals("1")?servicePayers:s.local("RANKING_FAVOURED_COURIER_FOR_STATION").equals("1")?station:List.>of(); + boolean only=!ignored&&onlyRows.stream().anyMatch(c->id(c,"cs_cr_fav_only")==1)||id(j,"jb_globaljob")==2; + var favStation=relations(station,s.inherited("RANKING_FAVOURED_COURIER_FOR_STATION"),1);var favPayer=relations(payers,s.inherited("RANKING_FAVOURED_COURIER_FOR_PAYER"),1); + var blocked=relations(station,s.inherited("RANKING_BLOCKED_COURIER_FOR_STATION"),0);blocked.addAll(relations(payers,s.inherited("RANKING_BLOCKED_COURIER_FOR_PAYER"),0)); + long hq=id(j,"hq_id_dispo");String mode=s.local("MODE_INTERMEDIATION");if(mode.isBlank())mode=text(one(db.queryForList("SELECT hq_invmode FROM headquarters WHERE hq_id=?",hq)),"hq_invmode"); + if(!reserved&&!Set.of("1","2","3").contains(mode))return reason(j,s,"BLOCKED","Der Vermittlungsmodus ist nicht konfiguriert.",starts); + var geo=new Geography(db,hq,mode,s.number("AUTORANKING_NEIGHBOUR_LEVEL",0,1000));String postal=text(stops.getFirst(),"ad_zipcode"); + var zones=reserved?Map.of():geo.zones(postal,false);var history=db.queryForList("SELECT * FROM autoranking WHERE jb_id=?",job); + var occupied=occupied(j);var filters=db.queryForList("SELECT crf_short,crf_type FROM courierfilter");var groups=db.queryForList("SELECT grp_id,grp_name,grp_group FROM groups WHERE hq_id=?",id(j,"hq_id")); + var payerRows=db.queryForList("SELECT cs.cs_group,br.br_group FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN branch br ON br.br_id=cmp.br_id WHERE cc.csc_id=?",payer); + var quarantineGroups=new HashSet();groups.stream().filter(g->text(g,"grp_name").equals("Quarantäne")).forEach(g->quarantineGroups.add(Long.toString(id(g,"grp_id")))); + boolean payerQuarantine=payerRows.stream().anyMatch(p->intersects(text(p,"cs_group"),quarantineGroups)||intersects(text(p,"br_group"),quarantineGroups)||groups.stream().anyMatch(g->intersects(text(g,"grp_group"),quarantineGroups)&&FleetAssignment.tokens(text(p,"cs_group")).contains(Long.toString(id(g,"grp_id"))))); + var countries=new HashSet();stops.forEach(t->countries.add(ClassicCandidates.country(text(t,"ad_country")))); + var candidates=new ArrayList();boolean multiple=s.local(AssignmentVehicles.SETTING).equals("1"); + for(var v:db.queryForList("SELECT cr.*,cv.crvh_id,cv.crvh_payload,cv.crvh_length,cv.crvh_width,cv.crvh_height,cv.crvh_position,cv.crvh_totalweight,cv.crvh_filter,u.usr_id AS driver_user_id,u.hq_id AS driver_user_hq,u.usr_country,cmp.cmp_visible,cmp.cmp_archived,cmp.cmp_comp,cmp.cmp_authenticated,(SELECT COUNT(*) FROM courier logged WHERE logged.cr_sid=cr.cr_sid) AS logged_drivers,(SELECT COUNT(*) FROM couriervehicle counted WHERE counted.crvh_sid=cr.cr_sid"+(multiple?"":" AND counted.cr_id=cr.cr_id")+") AS vehicle_count FROM courier cr LEFT JOIN couriervehicle cv ON cv.crvh_sid=cr.cr_sid"+(multiple?"":" AND cv.cr_id=cr.cr_id")+" LEFT JOIN user u ON u.usr_id=cr.usr_id LEFT JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE "+(reserved?"cr.cr_id=?":"cr.cr_available=1"),reserved?new Object[]{id(j,"cr_id_order")}:new Object[]{})){ + long courier=id(v,"cr_id");boolean favourite=!ignored&&(favStation.contains(courier)||favPayer.contains(courier)); + if(favourite&&s.local("RANKING_FAVOURED_COURIER_AREA_RESTRICTION").equals("1"))favourite=geo.zones(text(v,"cr_locationzipcode"),true).containsKey(postal); + int group=reserved?0:favourite?1:only?-1:zones.getOrDefault(text(v,"cr_locationzipcode"),-1);if(group<0)continue; + var prior=history.stream().filter(a->id(a,"cr_id")==courier).toList();int challenges=prior.isEmpty()?0:(int)id(prior.getFirst(),"ar_challenge"); + var reasons=blockers(j,v,s,group,challenges,prior.size(),occupied.contains(courier),payerQuarantine&&intersects(text(v,"cr_group"),quarantineGroups),countries,filters,blocked.contains(courier)); + 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))); + } + 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());} + Set relations(List> customers,String toggle,int relation){var result=new HashSet();if(!toggle.equals("1")||customers.isEmpty())return result;var ids=customers.stream().map(c->id(c,"cs_id")).toList();var args=new ArrayList(ids);args.add(relation);db.queryForList("SELECT DISTINCT cr_id FROM customercourier WHERE cs_id IN ("+String.join(",",Collections.nCopies(ids.size(),"?"))+") AND cscr_relation=?",args.toArray()).forEach(r->result.add(id(r,"cr_id")));return result;} + Set occupied(Map target){var result=new HashSet();db.queryForList("SELECT DISTINCT active.cr_id_order FROM job active JOIN tour ot ON ot.jb_id=active.jb_id AND ot.tr_sort=1 JOIN address oa ON oa.ad_id=ot.ad_id LEFT JOIN serviceplz op ON op.srvp_plz=oa.ad_zipcode LEFT JOIN serviceplztraveltime olt ON olt.srvp_id=op.srvp_id AND olt.hq_id=active.hq_id WHERE active.jb_id<>? AND active.jb_status IN (0,1) AND COALESCE(active.jb_storno,0)=0 AND (DATE_SUB(active.jb_ordertime,INTERVAL GREATEST(olt.srvpt_traveltime,30) MINUTE)<=NOW() OR (DATE_SUB(active.jb_ordertime,INTERVAL GREATEST(olt.srvpt_traveltime,30) MINUTE)<=? AND active.jb_ordertime>=?))",id(target,"jb_id"),dateTime(target,"jb_ordertime"),dateTime(target,"jb_ordertime")).forEach(r->result.add(id(r,"cr_id_order")));return result;} + static final class Geography { + final DatabaseService db;final long hq;final String mode;final int depth; + Geography(DatabaseService db,long hq,String mode,int depth){this.db=db;this.hq=hq;this.mode=mode;this.depth=depth;} + Map zones(String postal,boolean reverse){ + var result=new HashMap();if(postal.isBlank())return result;var source=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=?",postal);if(source.size()>1)throw bad("PLZ im Vermittlungsbestand ist mehrdeutig: "+postal);if(reverse||mode.equals("1"))result.put(postal,2);if(source.isEmpty())return result;long zip=id(source.getFirst(),"srvp_id"); + if(mode.equals("1")){db.queryForList("SELECT p.srvp_plz FROM serviceplzneighbour pn JOIN serviceplz p ON p.srvp_id=pn.srvp2_id WHERE pn.hq_id=? AND pn.srvp_id=?",hq,zip).forEach(r->result.putIfAbsent(text(r,"srvp_plz"),3));return result;} + var main=db.queryForList("SELECT ga.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea ga ON ga.srvpa_id=m.srvpa_id WHERE ga.hq_id=? AND m.srvp_id=?",hq,zip);if(main.size()>1)throw bad("PLZ gehört zu mehreren Vermittlungsbereichen.");if(!main.isEmpty())add(result,id(main.getFirst(),"srvpa_id"),2); + if(mode.equals("2")&&!main.isEmpty())for(var area:db.queryForList("SELECT ga.srvpa_id FROM serviceplzareaneighbour an JOIN serviceplzarea ga ON ga.srvpa_id=an.srvpa2_id WHERE an.srvpa_id=? AND ga.hq_id=?",id(main.getFirst(),"srvpa_id"),hq))add(result,id(area,"srvpa_id"),3); + 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));} + } + 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=2&&locating(s,time)?1:0,"ar_cr_order",p.reserved()?1:0); + int changed=db.update("UPDATE autoranking SET ar_challenge=?,ar_lastassigntime=?,ar_looseranking=?,ar_locating=?,ar_cr_order=? WHERE jb_id=? AND cr_id=?",selected.challenges()+1,time,1,values.get("ar_locating"),p.reserved()?1:0,job,selected.courier()); + if(changed==0){values.putAll(fields("jb_id",job,"cr_id",selected.courier()));BusinessSupport.insertWithoutKey(db,"autoranking",values);}else if(changed!=1)throw bad("Rankinghistorie ist nicht eindeutig."); + support.audit(new LegacyUser(0,0,hq,"Automatische Vermittlung",""),job,0,selected.courier(),6,"ASSIGN_STATUS="+(selected.group()==0?"RESERVED":selected.group()==1?"FAVOURED":selected.group()==2?"AREA":"NEIGHBOUR_"+(selected.group()-2))+"|SID="+selected.sid(),token); + receipt(job,token,fields("at",time,"courier",selected.courier(),"vehicle",selected.vehicle(),"group",selected.groupName(),"round",round+1,"challenge",selected.challenges()+1));return Result.OFFERED; + } + if(round+1db.update("UPDATE keepalive SET ka_lastexecutiontime=NOW() WHERE ka_process='autoranking'"));} + @Scheduled(initialDelay=60000,fixedDelay=15000) public synchronized void run(){ + if(!enabled)return;int checked=0,offered=0,fallback=0,failed=0; + try{var jobs=db.readTransaction(()->db.queryForList("SELECT jb_id,COALESCE(jb_ordertime,'1970-01-01 00:00:00') AS queue_time FROM job WHERE jb_status=8 AND EXISTS(SELECT 1 FROM parameter qp WHERE qp.hq_id=job.hq_id_dispo AND qp.emp_id=0 AND qp.par_key='AUTORANKING_ASSIGNMENT_ENABLED' AND qp.par_value='1') AND (COALESCE(jb_ordertime,'1970-01-01 00:00:00')>? OR (COALESCE(jb_ordertime,'1970-01-01 00:00:00')=? AND jb_id>?)) ORDER BY queue_time,jb_id LIMIT 40",cursorTime,cursorTime,cursorId));if(jobs.isEmpty()){cursorTime=LocalDateTime.of(1970,1,1,0,0);cursorId=0;}for(var row:jobs){cursorTime=dateTime(row,"queue_time");cursorId=id(row,"jb_id");checked++;try{var r=process(cursorId);if(r==Result.OFFERED)offered++;else if(r==Result.FALLBACK)fallback++;}catch(RuntimeException e){failed++;}}heartbeat();}catch(RuntimeException e){failed++;} + lastRun=new Run(LocalDateTime.now().withNano(0),checked,offered,fallback,failed);if(failed>0)org.slf4j.LoggerFactory.getLogger(AutomaticOffers.class).warn("Automatische Angebote: {} fehlgeschlagene Prüfungen; erneuter Versuch im nächsten Umlauf.",failed); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMail.java new file mode 100644 index 0000000..3beccc0 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMail.java @@ -0,0 +1,104 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +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; +import static de.stadtbote.votian.AutomaticOrderMailPolicy.*; + +/** Durable at-most-once SMTP handoff. Disabled until the PHP mail processes have been retired. */ +@RestController +@RequestMapping("/api/orders/{order}/automatic-mail") +class AutomaticOrderMail { + record Delivery(String token,long headquarters,long order,String key,String event,boolean additional,String state,String startedAt,String completedAt,String subject,OrderEntryRequestMail.Envelope envelope,String revision) {} + record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,String revision) {} + record Run(String at,int checked,int sent,int uncertain,int failed,String state) {} + final DatabaseService db;final BusinessSupport support;final AppointmentMailTransport transport;final boolean enabled;final Set headquarters; + final OrderCompletionProcessing completion;final OrderRoutingService routing;final String legacy,cc,bcc;final Map cursors=new HashMap<>();volatile Run lastRun=new Run("",0,0,0,0,"NOT_RUN"); + AutomaticOrderMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport,boolean enabled,String headquarters,String legacy,String cc,String bcc){this(db,support,transport,enabled,headquarters,legacy,cc,bcc,null);} + AutomaticOrderMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport,boolean enabled,String headquarters,String legacy,String cc,String bcc,OrderRoutingService routing){this(db,support,transport,enabled,headquarters,legacy,cc,bcc,routing,null);} + @org.springframework.beans.factory.annotation.Autowired + AutomaticOrderMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport, + @Value("${app.automatic-mail.enabled:false}")boolean enabled,@Value("${app.automatic-mail.headquarters:}")String headquarters, + @Value("${app.legacy-url:}")String legacy,@Value("${app.mail.order-cc:}")String cc,@Value("${app.mail.order-bcc:}")String bcc,OrderRoutingService routing,OrderCompletionProcessing completion){ + this.completion=completion;this.routing=routing;this.db=db;this.support=support;this.transport=transport;this.enabled=enabled;this.headquarters=parseHeadquarters(headquarters);this.legacy=legacy;this.cc=cc;this.bcc=bcc; + } + static Set parseHeadquarters(String raw){if(raw.isBlank())return Set.of();var ids=new LinkedHashSet();for(String part:raw.split(",",-1)){String s=part.strip();if(!s.matches("[1-9][0-9]{0,9}"))throw new IllegalArgumentException("Ungültige Niederlassung für automatische Auftragsmails.");ids.add(Long.parseLong(s));}if(ids.size()>100)throw new IllegalArgumentException("Zu viele Mail-Niederlassungen.");return Collections.unmodifiableSet(ids);} + LocalDateTime now(){return db.queryForObject("SELECT CURRENT_TIMESTAMP",java.sql.Timestamp.class).toLocalDateTime();} + Map job(long hq,long order){return one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",order,hq));} + LegacyUser actor(long hq){return new LegacyUser(0,0,hq,"Automatische Auftragsmail","");} + void available(long hq){if(!enabled||!headquarters.contains(hq))throw bad("Der native Statusmaildienst ist für diese Niederlassung ausgeschaltet.");if(!transport.configured())throw bad("SMTP ist nicht konfiguriert.");if(new AutomaticOrderMailPolicy(db).settings().legacyEnabled())throw bad("Der PHP-Automailer ist noch freigegeben. Zuerst den alten Dienst vollständig stilllegen.");var rows=db.queryForList("SELECT hq_id,hq_disabled FROM headquarters WHERE hq_id=?",hq);if(rows.size()!=1||id(rows.getFirst(),"hq_disabled")!=0)throw bad("Die Niederlassung ist nicht aktiv.");} + Delivery decode(Map row,long hq,long order){try{var d=support.json.readValue(text(row,"gdc_content"),Delivery.class);if(d.headquarters()!=hq||d.order()!=order||!Set.of("STARTED","SENT","UNCERTAIN").contains(d.state()))throw new IllegalArgumentException();return d;}catch(Exception e){throw bad("Der automatische Versandbeleg ist ungültig.");}} + Delivery receipt(long hq,long order,String key){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,key);if(rows.size()>1)throw bad("Der automatische Versandbeleg ist mehrdeutig.");return rows.isEmpty()?null:decode(rows.getFirst(),hq,order);} + @GetMapping Map 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",""));}} + 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)); + rows.addAll(new AutomaticTrackingMail(this).view(job,now)); + rows.addAll(new AutomaticCompletionMail(this).view(job)); + return fields("enabled",enabled&&headquarters.contains(user.headquartersId()),"legacyEnabled",settings.legacyEnabled(),"smtpConfigured",transport.configured(),"items",rows,"scope","Erfassung, Abholung und Erledigung; Standard- und zusätzliche Auftragsempfänger sowie Stationsabschlüsse, Vermittlungsstau mit Rückmeldung und Fahrzeuginfo nach Annahme und Endkundentracking; Betriebsbenachrichtigungen zu bestätigten Abschlussfehlern."); + });} + static String message(String state){return state.equals("SENT")?"Vom SMTP-Server angenommen; die endgültige Zustellung ist damit noch nicht bestätigt.":"Versand begonnen, aber nicht sicher bestätigt. Bitte SMTP-Protokolle prüfen; kein automatischer Neuversand.";} + static String safe(RuntimeException e){return e instanceof org.springframework.web.server.ResponseStatusException r?Objects.toString(r.getReason(),"Mailvorgaben konnten nicht gelesen werden."):"Mailvorgaben oder Versandbelege konnten nicht vollständig gelesen werden.";} + OrderEntryRequestMail.Envelope envelope(OrderCustomerMailContent renderer,List recipients){ + String from=renderer.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from=transport.from;from=AppointmentNotifications.address(from); + String copy=renderer.local("MAIL_CC_ADDRESS"),hidden=renderer.local("MAIL_BCC_ADDRESS");var seen=new HashSet();recipients.forEach(v->seen.add(v.toLowerCase(Locale.ROOT)));var copies=OrderEntryMail.addresses(copy.isBlank()?cc:copy).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();var hiddenCopies=OrderEntryMail.addresses(hidden.isBlank()?bcc:hidden).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();if(seen.size()>20)throw bad("Zu viele Empfänger einschließlich Cc/Bcc."); + return new OrderEntryRequestMail.Envelope(from,recipients,copies,hiddenCopies); + } + Prepared prepare(long hq,long order,Event event,boolean additional,LocalDateTime now,String token){ + var policy=new AutomaticOrderMailPolicy(db);var job=job(hq,order);var decision=policy.decide(job,event,additional,policy.settings(),now);if(!decision.state().equals("READY")||decision.recipients().isEmpty())return null; + String semaphore=additional?"automailer5":event.semaphore;var locks=db.queryForList("SELECT sp_content FROM "+support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname=?",order,semaphore);if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Mailvorgang ist noch gesperrt oder nicht eindeutig abgeschlossen."); + var renderer=new OrderCustomerMailContent(db,hq,legacy);var envelope=envelope(renderer,decision.recipients());var archive=dateTime(job,"jb_export_time")==null?null:new OrderArchive(db).load(order); + var content=renderer.automatic(job,now.toLocalDate(),new OrderDeliveryData(db,support.historySchema),token,archive,event); + return new Prepared(decision,content,envelope,revision(List.of(decision.revision(),content.revision(),envelope))); + } + String process(long hq,long order,Event event,boolean additional){ + available(hq);String key=(additional?"ADDITIONAL_":"STANDARD_")+event.name();if(receipt(hq,order,key)!=null)return "ALREADY_STARTED"; + LocalDateTime time=now();String token=UUID.randomUUID().toString();var prepared=db.readTransaction(()->prepare(hq,order,event,additional,time,token));if(prepared==null)return "SKIPPED"; + byte[] pdf=prepared.content().pdf()?OrderMailPdf.create(order,prepared.content().html()):null; + support.transactional("job","genericdatacontainer"); + Delivery claim=db.automaticOrderMailWriteTransaction(support.historySchema.replace("`",""),()->{ + available(hq);if(receipt(hq,order,key)!=null)return null;var current=prepare(hq,order,event,additional,now(),token);if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die automatischen Maildaten wurden geändert."); + var d=new Delivery(token,hq,order,key,event.label,additional,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",support.encode(d))); + // Consume the PHP event in the same commit as the durable claim. Ambiguous SMTP is never retried automatically. + if(additional){var row=one(db.queryForList("SELECT gdc_context FROM genericdatacontainer WHERE gdc_id=?",current.decision().marker()));db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?",Integer.toString(context(text(row,"gdc_context"))|1024|event.bit),current.decision().marker());} + else db.update("UPDATE job SET "+event.field+"=998 WHERE jb_id=? AND hq_id=?",order,hq); + support.audit(actor(hq),order,0,0,203,"Automatische "+event.label+"smail zur Übergabe vorgemerkt",token);return d; + }); + if(claim==null)return "ALREADY_STARTED"; + String state="SENT";try{var e=prepared.envelope();transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"Auftrag_"+order+".pdf",pdf);}catch(RuntimeException e){state="UNCERTAIN";} + finish(claim,state,event);return state; + } + void finish(Delivery claim,String state,Event event){ + db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Versandbeleg wurde verändert."); + var done=new Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),claim.additional(),state,claim.startedAt(),now().toString(),claim.subject(),claim.envelope(),claim.revision());db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",support.encode(done),state,id(rows.getFirst(),"gdc_id")); + if(state.equals("SENT")&&!claim.additional())db.update("UPDATE job SET "+event.field+"=999 WHERE jb_id=? AND hq_id=? AND "+event.field+"=998",claim.order(),claim.headquarters()); + support.audit(actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,"Automatische "+claim.event()+"smail: "+message(state),claim.token()+":result");return null; + }); + } + @Scheduled(initialDelay=60000,fixedDelay=30000) + public synchronized void run(){if(!enabled||headquarters.isEmpty())return;int checked=0,sent=0,uncertain=0,failed=0;String state="OK"; + for(long hq:headquarters){try{available(hq);var config=new AutomaticOrderMailPolicy(db).settings();LocalDateTime from=now().minusDays(Math.max(1,Math.max(config.completionDays(),config.pickupDays()))).minusHours(2); + var batch=db.queryForList("SELECT jb_id FROM job WHERE hq_id=? AND jb_id>? AND COALESCE(jb_storno,0)=0 AND (jb_ordertime>? OR jb_taketime>? OR jb_finishtime>? OR EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=job.jb_id AND g.gdc_gen_fieldname='ng_completion_result' AND g.gdc_context='FAILED')) ORDER BY jb_id LIMIT 30",hq,cursors.getOrDefault(hq,0L),from,from,from);if(batch.isEmpty())cursors.put(hq,0L); + for(var row:batch){long order=id(row,"jb_id");cursors.put(hq,order);checked++; + for(var kind:OrderCompletionProblems.Kind.values())try{String outcome=new AutomaticCompletionMail(this).process(hq,order,kind);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;} + for(var event:Event.values())for(boolean additional:List.of(false,true)){try{String outcome=process(hq,order,event,additional);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}} + for(var event:AutomaticMediationMail.Event.values()){try{String outcome=new AutomaticMediationMail(this).process(hq,order,event);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}} + try{String outcome=new AutomaticTakenMail(this).process(hq,order);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;} + try{var tracking=new AutomaticTrackingMail(this);for(long station:tracking.stationIds(order)){try{String outcome=tracking.process(hq,order,station);if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}}}catch(RuntimeException e){failed++;} + try{var stations=new AutomaticStationMail(this);for(var stop:stations.stops(order)){try{String outcome=stations.process(hq,order,id(stop,"tr_id"));if(outcome.equals("SENT"))sent++;if(outcome.equals("UNCERTAIN"))uncertain++;}catch(RuntimeException e){failed++;}}}catch(RuntimeException e){failed++;} + } + }catch(RuntimeException e){failed++;state="BLOCKED";}} + lastRun=new Run(LocalDateTime.now().withNano(0).toString(),checked,sent,uncertain,failed,state); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java b/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java new file mode 100644 index 0000000..50149b9 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticOrderMailPolicy.java @@ -0,0 +1,72 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Eligibility from the current server's inc_automailer.inc.php (workers 1, 2, 3 and 5). */ +final class AutomaticOrderMailPolicy { + enum Event { + CREATED("Erfassung","jb_automailsent3","cs_jbstatusmail3","automailer3","DISPOSITION",1), + PICKUP("Abholung","jb_automailsent2","cs_jbstatusmail2","automailer2","PICKUP",2), + COMPLETION("Erledigung","jb_automailsent","cs_jbstatusmail","automailer","COMPLETION",4); + final String label,field,customerField,semaphore,mode;final int bit; + Event(String label,String field,String customerField,String semaphore,String mode,int bit){this.label=label;this.field=field;this.customerField=customerField;this.semaphore=semaphore;this.mode=mode;this.bit=bit;} + } + record Settings(boolean legacyEnabled,int completionDays,int pickupDays,Set excluded) {} + record Decision(Event event,boolean additional,String state,String reason,List recipients,long marker,String revision) { + String key(){return (additional?"ADDITIONAL_":"STANDARD_")+event.name();} + } + final DatabaseService db; + AutomaticOrderMailPolicy(DatabaseService db){this.db=db;} + String global(String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND hq_id=0 AND emp_id=0",key);if(rows.size()>1)throw bad("Mehrdeutige automatische Mailvorgabe: "+key);return rows.isEmpty()?"":text(rows.getFirst(),"par_value");} + Settings settings(){String enabled=global("AUTOMAILER_ENABLED");if(!Set.of("","0","1").contains(enabled))throw bad("Die globale Automailer-Freigabe ist ungültig.");return new Settings(enabled.equals("1"),days(global("AUTOMAILER_STARTTIME_IN_DAYS")),days(global("AUTOMAILER_STARTTIME_PICKUP_IN_DAYS")),excluded(global("EMAIL_CRVHSID_NO_MAIL")));} + static int days(String value){if(value.isBlank())return 0;if(!value.matches("[0-9]{1,4}")||Integer.parseInt(value)>3660)throw bad("Der Zeitraum für automatische Mails ist ungültig.");return Integer.parseInt(value);} + static Set excluded(String value){if(value.isBlank())return Set.of();var result=new LinkedHashSet();for(String s:value.split(",",-1)){s=s.strip();if(!s.matches("[A-Za-z0-9_.-]{1,32}"))throw bad("Die Fahrzeug-Ausschlussliste für automatische Mails ist ungültig.");result.add(s.toUpperCase(Locale.ROOT));}if(result.size()>500)throw bad("Die Fahrzeug-Ausschlussliste ist zu umfangreich.");return Collections.unmodifiableSet(result);} + static boolean recent(LocalDateTime value,LocalDateTime lower){return value!=null&&value.isAfter(lower);} + static boolean statusEligible(Event event,Map job,Settings settings,LocalDateTime now,boolean additional){ + if(id(job,"jb_storno")!=0||id(job,"jb_offer")!=0)return false; + return switch(event){ + case CREATED -> Set.of(0L,1L,8L,9L).contains(id(job,"jb_status"))&&recent(dateTime(job,"jb_ordertime"),additional?now.minusDays(settings.completionDays()):now.minusHours(2)); + case PICKUP -> id(job,"jb_status")==1&&recent(dateTime(job,"jb_taketime"),now.minusDays(additional?settings.completionDays():settings.pickupDays())); + case COMPLETION -> id(job,"jb_status")==2&&recent(dateTime(job,"jb_finishtime"),now.minusDays(settings.completionDays())); + }; + } + static int context(String value){if(value.matches("[0-9]{1,9}"))return Integer.parseInt(value);return 1024;} + static boolean additionalDone(String value,Event event){return Set.of("MAIL_SENT","MAIL_NOT_SENT").contains(value)||(context(value)&(1024|event.bit))==(1024|event.bit);} + List> customer(long center,long hq){return db.queryForList(""" + SELECT cc.csc_id,cs.cs_id,cs.cs_jbstatusmail,cs.cs_jbstatusmail2,cs.cs_jbstatusmail3, + cs.cs_jbstatusmail2csc,cs.cs_jbstatusmail_fields,cs.cs_jb_jam_waittime,u.usr_email,ca.cscad_email + FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id + JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id + JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=2 + WHERE cc.csc_id=? AND cs.hq_id=? + """,center,hq);} + Decision decide(Map job,Event event,boolean additional,Settings settings,LocalDateTime now){ + 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(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.";}} + if(state.equals("READY")){ + if(additional){var rows=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'",id(job,"jb_id"));sources.add(rows);if(rows.size()>1)throw bad("Die zusätzlichen Auftragsempfänger sind mehrdeutig."); + if(rows.isEmpty()||text(rows.getFirst(),"gdc_content").isBlank()){state="DISABLED";reason="Keine zusätzlichen Auftragsempfänger hinterlegt.";} + else {var row=rows.getFirst();marker=id(row,"gdc_id");if(additionalDone(text(row,"gdc_context"),event)){state="DONE";reason="Dieser Status wurde für die zusätzlichen Empfänger bereits verarbeitet.";} + else {var customer=db.queryForList("SELECT cs.* FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cc.csc_id=? AND cs.hq_id=?",id(job,"csc_id_payer"),id(job,"hq_id"));sources.add(customer);if(customer.size()!=1)throw bad("Die Kunden-Mailvorgaben sind nicht eindeutig verknüpft.");if(id(customer.getFirst(),event.customerField)!=1){state="DISABLED";reason="Dieser Statusversand ist beim Rechnungskunden ausgeschaltet.";}else for(String address:OrderEntryMail.addresses(text(row,"gdc_content").replace(" ","")))recipients.putIfAbsent(address.toLowerCase(Locale.ROOT),address);}} + }else{ + var centers=new LinkedHashSet();centers.add(id(job,"csc_id_payer"));if(id(job,"csc_id_payer_cash")>0)centers.add(id(job,"csc_id_payer_cash")); + for(long center:centers){var customer=customer(center,id(job,"hq_id"));sources.add(customer);if(customer.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig verknüpft.");if(customer.isEmpty()||id(customer.getFirst(),event.customerField)!=1)continue;var row=customer.getFirst();long selection=id(row,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");String raw=text(row,selection==1&&!text(row,"cscad_email").isBlank()?"cscad_email":"usr_email"); + // PHP only selects pickup/creation if the explicitly selected address exists. + if(event!=Event.COMPLETION&&text(row,selection==1?"cscad_email":"usr_email").isBlank())continue; + for(String address:OrderEntryMail.addresses(raw.replace(" ","")))recipients.putIfAbsent(address.toLowerCase(Locale.ROOT),address);} + if(recipients.isEmpty()){state="DISABLED";reason="Kein eingeschalteter Kundenversand mit gültiger Empfängeradresse.";} + } + } + } + return new Decision(event,additional,state,reason,List.copyOf(recipients.values()),marker,revision(List.of(job,event,additional,settings,sources,recipients))); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java new file mode 100644 index 0000000..a6bcfe8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMail.java @@ -0,0 +1,92 @@ +package de.stadtbote.votian; + +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; + +/** Station completion notifications from current PHP automailer4, with durable SMTP claims. */ +class AutomaticStationMail { + record Decision(String state,String reason,List recipients,String revision) {} + record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,String revision) {} + final AutomaticOrderMail service; + AutomaticStationMail(AutomaticOrderMail service){this.service=service;} + static String key(long station){return "STATION_"+station;} + List> stops(long order){ + var rows=service.db.queryForList("SELECT t.*,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,t.tr_id LIMIT 1001",order); + if(rows.size()>1000)throw bad("Zu viele Stationen für die automatische Mailprüfung.");return rows; + } + Map stop(long order,long station){return one(service.db.queryForList("SELECT t.*,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=? AND t.tr_id=?",order,station));} + static boolean eligible(Map job,Map stop,LocalDateTime now){ + var orderTime=dateTime(job,"jb_ordertime");var finish=dateTime(stop,"tr_finishtime"); + return id(job,"jb_status")==1&&id(job,"jb_storno")==0&&id(stop,"tr_status")==1&&orderTime!=null&&!orderTime.isBefore(now.minusDays(1))&&finish!=null&&finish.isAfter(now.minusDays(1)); + } + static boolean photoReady(String raw,LocalDateTime finish,LocalDateTime now){ + if(raw.isBlank())return true;int separator=raw.indexOf('|');if(separator<1)return false; + String count=raw.substring(0,separator).strip();return count.matches("[0-9]{1,6}")&&Integer.parseInt(count)>0&&finish.isBefore(now.minusMinutes(1)); + } + Decision decide(Map job,Map stop,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){ + String state="WAITING",reason="Die Station muss erledigt sein; Auftrag und Stationsabschluss dürfen höchstens einen Tag zurückliegen."; + var sources=new ArrayList();var recipients=new LinkedHashMap();long station=id(stop,"tr_id"); + var markers=service.db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='auto_mail_station'",station);sources.add(markers); + 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.";} + 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); + if(photos.size()>1)throw bad("Die Fotovormerkung dieser Station ist mehrdeutig."); + if(!photoReady(photos.isEmpty()?"":text(photos.getFirst(),"gdc_content"),dateTime(stop,"tr_finishtime"),now)){state="WAITING";reason="Die Fotovormerkung oder die Minute Wartezeit lässt noch keinen Stationsversand zu.";} + } + if(state.equals("READY")){ + var policy=new AutomaticOrderMailPolicy(service.db);var centers=new LinkedHashSet();centers.add(id(job,"csc_id_payer"));if(id(job,"csc_id_payer_cash")>0)centers.add(id(job,"csc_id_payer_cash")); + for(long center:centers){var customers=policy.customer(center,id(job,"hq_id"));sources.add(customers);if(customers.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig verknüpft.");if(customers.isEmpty())continue;var customer=customers.getFirst(); + if(!OrderCustomerMailContent.bit(text(customer,"cs_jbstatusmail_fields"),4))continue;long selection=id(customer,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig."); + String raw=text(customer,selection==1&&!text(customer,"cscad_email").isBlank()?"cscad_email":"usr_email");for(String address:OrderEntryMail.addresses(raw.replace(" ","")))recipients.putIfAbsent(address.toLowerCase(Locale.ROOT),address); + } + if(recipients.isEmpty()){state="DISABLED";reason="Keine eingeschaltete Stationsmail mit gültiger Kundenadresse.";} + } + } + return new Decision(state,reason,List.copyOf(recipients.values()),revision(List.of(job,stop,settings,sources,recipients))); + } + List> view(Map job,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){ + 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())); + }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; + } + Prepared prepare(long hq,long order,long station,LocalDateTime now){ + var job=service.job(hq,order);var stop=stop(order,station);var policy=new AutomaticOrderMailPolicy(service.db);var decision=decide(job,stop,policy.settings(),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='tr' AND sp_obj_id=? AND sp_fieldname='automailer4'",station); + if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Stationsmailvorgang ist noch gesperrt oder nicht eindeutig abgeschlossen."); + var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);String from=AppointmentNotifications.address(renderer.fallback("MAIL_SENDER_ADDRESS")); + String hidden=renderer.parameter(0,"MAIL_BCC_STATION_ADDRESS");if(hidden.isBlank())hidden=renderer.fallback("MAIL_BCC_ADDRESS");var seen=new HashSet();decision.recipients().forEach(v->seen.add(v.toLowerCase(Locale.ROOT))); + var copies=OrderEntryMail.addresses(renderer.fallback("MAIL_CC_ADDRESS")).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();var hiddenCopies=OrderEntryMail.addresses(hidden).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();if(seen.size()>20)throw bad("Zu viele Empfänger einschließlich Cc/Bcc."); + var envelope=new OrderEntryRequestMail.Envelope(from,decision.recipients(),copies,hiddenCopies);var content=AutomaticStationMailContent.build(renderer,job,stop,now.toLocalDate(),service.support.historySchema); + return new Prepared(decision,content,envelope,revision(List.of(decision.revision(),content.revision(),envelope))); + } + String process(long hq,long order,long station){ + service.available(hq);String key=key(station);if(service.receipt(hq,order,key)!=null)return "ALREADY_STARTED";var time=service.now();String token=UUID.randomUUID().toString();var prepared=service.db.readTransaction(()->prepare(hq,order,station,time));if(prepared==null)return "SKIPPED"; + service.support.transactional("genericdatacontainer");var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{ + service.available(hq);if(service.receipt(hq,order,key)!=null)return null;var current=prepare(hq,order,station,service.now());if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Stationsmaildaten wurden geändert."); + var delivery=new AutomaticOrderMail.Delivery(token,hq,order,key,"Station "+id(stop(order,station),"tr_sort"),false,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision()); + service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",service.support.encode(delivery))); + service.db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",station,"gdc_gen_fieldname","auto_mail_station","gdc_context",token,"gdc_content","998")); + service.support.audit(service.actor(hq),order,0,0,203,"Automatische Stationsmail zur Übergabe vorgemerkt",token);return delivery; + });if(claim==null)return "ALREADY_STARTED"; + String state="SENT";try{var e=prepared.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";} + finish(claim,station,state);return state; + } + void finish(AutomaticOrderMail.Delivery claim,long station,String state){ + service.db.writeTransaction(()->{ + var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Stationsmailbeleg wurde verändert."); + var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision());service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id")); + if(state.equals("SENT"))service.db.update("UPDATE genericdatacontainer SET gdc_content='999' WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='auto_mail_station' AND gdc_context=? AND gdc_content='998'",station,claim.token()); + service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,state.equals("SENT")?22:23,"Automatische Stationsmail: "+AutomaticOrderMail.message(state),claim.token()+":result");return null; + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java new file mode 100644 index 0000000..bd89a33 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticStationMailContent.java @@ -0,0 +1,44 @@ +package de.stadtbote.votian; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.OrderCustomerMailContent.*; + +/** The station-only body of current automailer4.php. It contains neither prices nor other stops. */ +final class AutomaticStationMailContent { + static Content build(OrderCustomerMailContent c,Map job,Map stop,LocalDate day,String historySchema){ + c.language=OrderMailLanguage.load(c.db,c.parameter(0,"SYSTEM_LANGUAGE_DEFAULT"),false,"include/mcglobal.inc.php"); + var customer=one(c.db.queryForList("SELECT cs.cs_id,cs.cs_jbstatusmail_fields FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cc.csc_id=? AND cs.hq_id=?",id(job,"csc_id_payer"),c.hq)); + String date=c.parameter(0,"MAIL_JOBDATE").equals("1")?time(job,"jb_ordertime"):day.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); + StringBuilder details=new StringBuilder("

").append(escape(c.t("Transportdatum:"))).append(' ').append(escape(date)).append("

") + .append(c.row("AUFTRAG:",id(job,"jb_id"))).append(c.row("KOMMISSIONSNUMMER:",text(stop,"tr_commission_no"))).append(c.row("ERLEDIGUNGSZEITPUNKT:",time(stop,"tr_finishtime"))) + .append(c.row("ERLEDIGTE STATION:",text(stop,"tr_comp")+" "+text(stop,"tr_comp2"))).append(c.row("Annahme",text(stop,"tr_signname"))) + .append(c.row("Straße + Hausnr.",text(stop,"ad_street")+" "+text(stop,"tr_hsno"))).append(c.row("PLZ + Ort",text(stop,"ad_zipcode")+" "+text(stop,"ad_city"))); + // The PHP station mail exposes only the delivery event and its remark, not general station/internal remarks. + if(c.fallback("MASK_JOBDETAILS_STATIONS_POD_SCAN_EVENTS").equals("1")&&bit(text(customer,"cs_jbstatusmail_fields"),8)){ + var data=new OrderDeliveryData(c.db,historySchema);String code=data.value("tr",id(stop,"tr_id"),"del_code"),remark=data.value("tr",id(stop,"tr_id"),"del_rem"); + String label=code.isBlank()?"":OrderDeliveryData.deliveryLabel(code,0,c.object("JOBDETAILS_STATIONS_DELIVERY_CODES",id(customer,"cs_id"))); + details.append(c.row("EVENT-BEMERKUNG:",label+" "+remark)); + } + details.append("
");boolean early=c.parameter(0,"MAIL_JOBDATA").equals("1");var warnings=new ArrayList(); + StringBuilder body=new StringBuilder("
").append(rich(c.mailText("MAIL_PRE_SALUTATION_TEXT","Sehr geehrte Damen und Herren,",false))).append("

").append(escape(c.t("die Erledigung der Station erfolgte."))).append("

");if(early)body.append(details); + body.append("

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

").append(rich(c.t(c.local("MAIL_SALUTATION_TEXT")))).append("
"); + String logo=c.local("IMG_LOGO_NAME"),height=c.local("IMG_LOGO_HEIGHT"),width=c.local("IMG_LOGO_WIDTH"); + 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."); + } + 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("
"); + for(int i=1;i<=5;i++){String value=c.fallback("MAIL_FOOTER_RESPONSIBILITY"+(i==1?"":"_"+i));if(value.isBlank())break;body.append("

").append(rich(value)).append("

");}body.append("

").append(rich(c.local("MAIL_FOOTER_THINK_GREEN"))).append("

"); + } + if(!early)body.append(details);String style=NewsletterTemplate.safeStyle("font-family:"+c.defaulted("MAIL_CSS_FONT_TYPE","Arial, sans-serif")); + String html=""+body+""; + if(html.length()>400000)throw bad("Die Stationsmail ist zu umfangreich.");String subject=c.t("Station")+" "+id(stop,"tr_sort")+" "+c.t("des Transportauftrags!")+" "+id(job,"jb_id")+" "+c.t("wurde erledigt!");warnings.addAll(c.language.warnings()); + return new Content(subject,html,List.copyOf(warnings),revision(List.of(job,stop,customer,c.parameters,html,subject,c.language.revision)),false); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java new file mode 100644 index 0000000..3631e13 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticTakenMail.java @@ -0,0 +1,98 @@ +package de.stadtbote.votian; + +import java.math.*; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** automailer9: vehicle information after acceptance while pickup is still outstanding. */ +class AutomaticTakenMail { + static final String KEY="TAKEN_DELAY",LABEL="Fahrzeuginfo nach Annahme",FIELD="jb_email_by_delay"; + record Decision(String state,String reason,List recipients,long marker,String revision) {} + record Prepared(Decision decision,OrderCustomerMailContent.Content content,OrderEntryRequestMail.Envelope envelope,String revision) {} + final AutomaticOrderMail service; + AutomaticTakenMail(AutomaticOrderMail service){this.service=service;} + static long delayMicros(String raw){ + BigDecimal value; + try{value=new BigDecimal(raw.strip());}catch(NumberFormatException e){return 0;} + if(value.abs().compareTo(BigDecimal.valueOf(31_536_000))>0)throw bad("Die Wartezeit der Fahrzeuginfo-Mail überschreitet ein Jahr."); + return value.movePointRight(6).setScale(0,RoundingMode.DOWN).longValueExact(); + } + static boolean window(Map job,AutomaticOrderMailPolicy.Settings settings,long delay,LocalDateTime now){ + var ordered=dateTime(job,"jb_ordertime");var taken=dateTime(job,"jb_taketime"); + return id(job,"jb_status")==1&&id(job,"jb_storno")==0&&ordered!=null&&ordered.isAfter(now.minusDays(settings.completionDays()))&&taken!=null&&taken.plus(delay,ChronoUnit.MICROS).isBefore(now); + } + Decision decide(Map job,AutomaticOrderMailPolicy.Settings settings,LocalDateTime now){ + var policy=new AutomaticOrderMailPolicy(service.db);long delay=delayMicros(policy.global("AUTOMAILER_TIME_AFTER_TAKEN_BEFORE_PICKUP_IN_SECONDS")); + String state="WAITING",reason="Annahmezeit oder Auftragsstatus erfüllt die Wartezeit vor Abholung noch nicht.";long marker=0;var sources=new ArrayList();var recipients=new ArrayList(); + if(window(job,settings,delay,now)){ + state="READY";reason="Der Auftrag ist angenommen, die Wartezeit abgelaufen und die erste Station noch offen."; + 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 markers=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",id(job,"jb_id"),FIELD);sources.add(markers); + if(markers.size()>1)throw bad("Die Fahrzeuginfo-Mailkennzeichnung ist mehrdeutig."); + if(!markers.isEmpty()){marker=id(markers.getFirst(),"gdc_id");if(Set.of("SENT_OK","SENT_NOK").contains(text(markers.getFirst(),"gdc_context"))){state="DONE";reason="Die bestehende Fahrzeuginfo-Mailkennzeichnung lässt keinen Versand zu.";}} + } + if(state.equals("READY")){ + var stops=service.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(stops.getFirst().get("tr_status")==null||id(stops.getFirst(),"tr_status")!=0){state="WAITING";reason="Die erste Station ist nicht mehr offen.";} + } + if(state.equals("READY")){ + var customer=policy.customer(id(job,"csc_id_payer"),id(job,"hq_id"));sources.add(customer); + if(customer.size()>1)throw bad("Die Kunden-Mailvorgaben sind mehrdeutig."); + if(customer.isEmpty()||!OrderCustomerMailContent.bit(text(customer.getFirst(),"cs_jbstatusmail_fields"),6)){state="DISABLED";reason="Die Kundenoption KFZ-Kennzeichen für Auftragsmails ist ausgeschaltet.";} + else{var row=customer.getFirst();long selection=id(row,"cs_jbstatusmail2csc");if(!Set.of(0L,1L).contains(selection))throw bad("Die Empfängerauswahl des Kunden ist ungültig.");recipients.addAll(OrderEntryMail.addresses(text(row,selection==1?"cscad_email":"usr_email").replace(" ","")));if(recipients.isEmpty()){state="DISABLED";reason="Die ausgewählte Kundenadresse ist leer.";}} + } + if(state.equals("READY")){ + var courier=service.db.queryForList("SELECT cr.cr_id,cv.crvh_sid,cv.crvh_vh_sign,u.usr_phone FROM courier cr JOIN user u ON u.usr_id=cr.usr_id JOIN couriervehicle cv ON cv.cr_id=cr.cr_id AND cv.crvh_sid=? WHERE cr.cr_id=?",text(job,"cr_sid"),id(job,"cr_id"));sources.add(courier); + if(courier.size()>1)throw bad("Der ausführende Transporteur oder sein Fahrzeug ist mehrdeutig."); + if(courier.isEmpty()){state="WAITING";reason="Ein eindeutig zugeordneter Transporteur mit Fahrzeug und Benutzerkonto fehlt.";} + } + } + return new Decision(state,reason,List.copyOf(recipients),marker,revision(List.of(job,settings,delay,sources,recipients))); + } + 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()); + }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){ + var job=service.job(hq,order);var decision=decide(job,new AutomaticOrderMailPolicy(service.db).settings(),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='automailer9'",order); + if(locks.size()>1||locks.stream().anyMatch(r->!text(r,"sp_content").isBlank()))throw bad("Ein PHP-Fahrzeuginfo-Mailvorgang ist noch gesperrt."); + var renderer=new OrderCustomerMailContent(service.db,hq,service.legacy);var envelope=service.envelope(renderer,decision.recipients()); + var archive=dateTime(job,"jb_export_time")==null?null:new OrderArchive(service.db).load(order); + var content=renderer.taken(job,now.toLocalDate(),new OrderDeliveryData(service.db,service.support.historySchema),token,archive); + return new Prepared(decision,content,envelope,revision(List.of(decision.revision(),content.revision(),envelope))); + } + String process(long hq,long order){ + service.available(hq);if(service.receipt(hq,order,KEY)!=null)return "ALREADY_STARTED"; + var time=service.now();String token=UUID.randomUUID().toString();var prepared=service.db.readTransaction(()->prepare(hq,order,time,token));if(prepared==null)return "SKIPPED"; + byte[] pdf=prepared.content().pdf()?OrderMailPdf.create(order,prepared.content().html()):null;service.support.transactional("genericdatacontainer"); + var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{ + service.available(hq);if(service.receipt(hq,order,KEY)!=null)return null;var current=prepare(hq,order,service.now(),token); + if(current==null||!current.revision().equals(prepared.revision()))throw OrderEntryService.conflict("Die Fahrzeuginfo-Maildaten wurden geändert."); + var d=new AutomaticOrderMail.Delivery(token,hq,order,KEY,LABEL,false,"STARTED",time.toString(),"",prepared.content().subject(),prepared.envelope(),prepared.revision()); + service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",KEY,"gdc_context","STARTED","gdc_content",service.support.encode(d))); + if(current.decision().marker()==0)service.db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname",FIELD,"gdc_content","","gdc_context","SENT_NOK")); + else if(service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_NOK' WHERE gdc_id=?",current.decision().marker())!=1)throw bad("Die Fahrzeuginfo-Mailkennzeichnung wurde verändert."); + service.support.audit(service.actor(hq),order,0,0,203,LABEL+": automatische Mail zur Übergabe vorgemerkt",token);return d; + });if(claim==null)return "ALREADY_STARTED"; + String state="SENT";try{var e=prepared.envelope();service.transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),prepared.content().subject(),prepared.content().html(),"Auftrag_"+order+".pdf",pdf);}catch(RuntimeException e){state="UNCERTAIN";} + finish(claim,state);return state; + } + void finish(AutomaticOrderMail.Delivery claim,String state){service.db.writeTransaction(()->{ + var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),KEY);var current=service.decode(one(rows),claim.headquarters(),claim.order()); + if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Fahrzeuginfo-Mailbeleg wurde verändert."); + var markers=service.db.lockRows("SELECT gdc_id,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),FIELD); + var marker=one(markers);if(!text(marker,"gdc_context").equals("SENT_NOK"))throw bad("Die Fahrzeuginfo-Mailkennzeichnung wurde verändert."); + if(state.equals("SENT"))service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_OK' WHERE gdc_id=?",id(marker,"gdc_id")); + var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),KEY,LABEL,false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision()); + service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id")); + service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,193,LABEL+": "+AutomaticOrderMail.message(state),claim.token()+":result");return null; + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java b/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java new file mode 100644 index 0000000..1b2478e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/AutomaticTrackingMail.java @@ -0,0 +1,87 @@ +package de.stadtbote.votian; + +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; + +/** automailer7: one end-customer mail per station, with central PHP-compatible tracking links. */ +class AutomaticTrackingMail { + static final String FIELD="tr_tracking"; + record Decision(String state,String reason,List recipients,Map marker,Map customer) {} + record Prepared(Decision decision,Map stop,List> stops,Map courier,TrackingMailContent.Config config,String revision) {} + final AutomaticOrderMail service; + AutomaticTrackingMail(AutomaticOrderMail service){this.service=service;} + static String key(long station){return "TRACKING_"+station;} + static boolean window(Map job,LocalDateTime now){var ordered=dateTime(job,"jb_ordertime");return id(job,"jb_status")==1&&id(job,"jb_storno")==0&&ordered!=null&&!ordered.isBefore(now.minusDays(1));} + List> stops(long order){var rows=service.db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 1001",order);if(rows.size()>1000)throw bad("Zu viele Tracking-Stationen.");return rows;} + List stationIds(long order){return service.db.queryForList("SELECT DISTINCT t.tr_id FROM tour t JOIN genericdatacontainer ON gdc_obj_id=t.tr_id AND gdc_obj_type='tr' AND gdc_gen_fieldname='tr_tracking' WHERE t.jb_id=? ORDER BY t.tr_id LIMIT 1001",order).stream().map(r->id(r,"tr_id")).toList();} + Decision decide(Map job,List> stops,long station,LocalDateTime now){ + var markers=service.db.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station); + if(markers.size()>1)throw bad("Die Tracking-Kennzeichnung der Station ist mehrdeutig."); + Map marker=markers.isEmpty()?Map.of():markers.getFirst(),customer=Map.of();String state="READY",reason="Der Auftrag ist abgeholt und für die Tracking-Mail freigegeben.";var recipients=new ArrayList(); + if(marker.isEmpty()||text(marker,"gdc_content").isBlank()){state="DISABLED";reason="Für diese Station ist kein Tracking-Empfänger hinterlegt.";} + else if(marker.get("gdc_context")==null||Set.of("SENT_OK","SENT_NOK").contains(text(marker,"gdc_context"))){state="DONE";reason="Die vorhandene Tracking-Kennzeichnung lässt keinen Versand zu.";} + else if(!new AutomaticOrderMailPolicy(service.db).global("AUTOMAILER_2_TRACKING_ENABLED").equals("1")){state="DISABLED";reason="Automatische Endkunden-Tracking-Mails sind ausgeschaltet.";} + else if(!window(job,now)){state="WAITING";reason="Der Auftrag muss angenommen, stornofrei und höchstens einen Tag alt sein.";} + if(state.equals("READY")){ + var first=stops.stream().filter(r->id(r,"tr_sort")==1).toList();if(first.size()!=1)throw bad("Die erste Auftragsstation ist nicht eindeutig."); + if(id(first.getFirst(),"tr_status")!=1){state="WAITING";reason="Die erste Station muss abgeholt sein.";} + } + if(state.equals("READY")){ + var customers=service.db.queryForList("SELECT cs.cs_id,cs.cs_tracking,cs.cs_blur_markup_time,cs.cs_charging_time,cmp.cmp_comp FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id=?",id(job,"csc_id_payer")); + if(customers.size()>1)throw bad("Der Tracking-Kunde ist mehrdeutig."); + if(customers.isEmpty()||id(customers.getFirst(),"cs_tracking")!=1){state="DISABLED";reason="Endkundentracking ist für den zahlenden Kunden nicht eingeschaltet.";}else customer=customers.getFirst(); + } + if(state.equals("READY")){ + String address=text(marker,"gdc_content").strip().split("\\|",-1)[0].replace(" ",""); + recipients.add(AppointmentNotifications.address(address)); + if(!service.db.queryForList("SELECT sp_content FROM "+service.support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname='automailer7' AND sp_content=?",id(job,"jb_id"),address).isEmpty())throw bad("Ein PHP-Tracking-Mailvorgang für diesen Empfänger ist noch gesperrt."); + } + return new Decision(state,reason,List.copyOf(recipients),marker,customer); + } + Prepared prepare(long hq,long order,long station,LocalDateTime now){ + var job=service.job(hq,order);var stops=stops(order);var stop=one(stops.stream().filter(r->id(r,"tr_id")==station).toList());var decision=decide(job,stops,station,now);if(!decision.state().equals("READY"))return null; + if(stops.stream().map(r->id(r,"tr_sort")).distinct().count()!=stops.size())throw bad("Die Tracking-Tourreihenfolge ist mehrdeutig."); + var courierRows=service.db.queryForList("SELECT cr.cr_id,cr.cr_gps_lat,cr.cr_gps_long,cr.cr_gps_time FROM courier cr WHERE cr.cr_id=?",id(job,"cr_id"));if(courierRows.size()>1)throw bad("Der Tracking-Transporteur ist mehrdeutig.");Map courier=courierRows.isEmpty()?Map.of():courierRows.getFirst(); + long charging=id(decision.customer(),"cs_charging_time"),blur=id(decision.customer(),"cs_blur_markup_time");if(charging<0||charging>60||blur<0||blur>60)throw bad("Die Tracking-Zeitvorgaben des Kunden sind ungültig."); + var config=TrackingMailContent.config(new OrderCustomerMailContent(service.db,hq,service.legacy)); + return new Prepared(decision,stop,stops,courier,config,revision(List.of(job,stops,courier,decision,config.revision()))); + } + List> view(Map job,LocalDateTime now){ + 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())); + }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; + } + String process(long hq,long order,long station){ + service.available(hq);String key=key(station);if(service.receipt(hq,order,key)!=null)return "ALREADY_STARTED"; + var time=service.now();var prepared=service.db.readTransaction(()->prepare(hq,order,station,time));if(prepared==null)return "SKIPPED"; + var estimate=TrackingMailEta.estimate(text(prepared.decision().marker(),"gdc_content"),prepared.courier(),prepared.stops(),id(prepared.stop(),"tr_sort"),(int)id(prepared.decision().customer(),"cs_charging_time"),time,service.routing); + service.support.transactional("genericdatacontainer");service.available(hq); + String tracking=service.db.ensureStationTrackingToken(prepared.config().instance(),station); + var content=TrackingMailContent.build(prepared.config(),prepared.decision().customer(),prepared.stop(),estimate,tracking); + var envelope=new OrderEntryRequestMail.Envelope(prepared.config().from(),prepared.decision().recipients(),List.of(),List.of());String token=UUID.randomUUID().toString(); + var claim=service.db.automaticOrderMailWriteTransaction(service.support.historySchema.replace("`",""),()->{ + service.available(hq);if(service.receipt(hq,order,key)!=null)return null;var now=service.now();var current=prepare(hq,order,station,now); + if(current==null||!current.revision().equals(prepared.revision())||now.isBefore(time)||!now.isBefore(time.plusMinutes(15)))throw OrderEntryService.conflict("Die Tracking-Maildaten wurden geändert oder die Fahrzeit ist veraltet."); + var d=new AutomaticOrderMail.Delivery(token,hq,order,key,"Tracking · Station "+id(prepared.stop(),"tr_sort"),false,"STARTED",time.toString(),"",content.subject(),envelope,prepared.revision()); + service.db.insert("genericdatacontainer",fields("gdc_obj_type","ngautomail","gdc_obj_id",order,"gdc_gen_fieldname",key,"gdc_context","STARTED","gdc_content",service.support.encode(d))); + if(service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_NOK',gdc_content=? WHERE gdc_id=?",estimate.cacheContent(),id(current.decision().marker(),"gdc_id"))!=1)throw bad("Die Tracking-Kennzeichnung wurde verändert."); + service.support.audit(service.actor(hq),order,0,0,203,"Endkunden-Tracking-Mail zur Übergabe vorgemerkt",token);return d; + });if(claim==null)return "ALREADY_STARTED"; + String state="SENT";try{service.transport.sendOrderHtml(envelope.from(),envelope.to(),List.of(),List.of(),content.subject(),content.html(),"",null);}catch(RuntimeException e){state="UNCERTAIN";} + finish(claim,station,state);return state; + } + void finish(AutomaticOrderMail.Delivery claim,long station,String state){service.db.writeTransaction(()->{ + var rows=service.db.lockRows("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngautomail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",claim.order(),claim.key());var current=service.decode(one(rows),claim.headquarters(),claim.order());if(!current.token().equals(claim.token())||!current.state().equals("STARTED"))throw bad("Der Tracking-Mailbeleg wurde verändert."); + var marker=one(service.db.lockRows("SELECT gdc_id,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station));if(!text(marker,"gdc_context").equals("SENT_NOK"))throw bad("Die Tracking-Kennzeichnung wurde verändert."); + if(state.equals("SENT"))service.db.update("UPDATE genericdatacontainer SET gdc_context='SENT_OK' WHERE gdc_id=?",id(marker,"gdc_id")); + var done=new AutomaticOrderMail.Delivery(claim.token(),claim.headquarters(),claim.order(),claim.key(),claim.event(),false,state,claim.startedAt(),service.now().toString(),claim.subject(),claim.envelope(),claim.revision());service.db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",service.support.encode(done),state,id(rows.getFirst(),"gdc_id")); + service.support.audit(service.actor(claim.headquarters()),claim.order(),0,0,193,"Endkunden-Tracking-Mail: "+AutomaticOrderMail.message(state),claim.token()+":result");return null; + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/BatchInvoiceExports.java b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceExports.java new file mode 100644 index 0000000..35bce0b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceExports.java @@ -0,0 +1,49 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Same-server, transactional invoice rows consumed by the existing BI/B2B integration. */ +final class BatchInvoiceExports { + private final DatabaseService db;private final BusinessSupport support;private final String table; + BatchInvoiceExports(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;this.table=support.historySchema+".b2b_objects";} + String schema(){return support.historySchema.replace("`","");} + void requireTransactional(){support.transactional(schema()+".b2b_objects");} + void insert(long job,long hq,List items,LocalDateTime at) { + // Only called for freshly allocated jobs, inside the booking transaction. + // No broad PHP-style DELETE: type 300 is also used for unrelated scan/status records. + for(var item:items)BusinessSupport.insertWithoutKey(db,table,fields("bo_ver",0,"bo_type",300,"bo_state",0, + "bo_ext_id0",job,"bo_ext_id1",hq,"bo_ext_id2",0,"bo_obj_data",support.encode(item.legacy()),"bo_createtime",at)); + } + void attach(long hq,List> jobs) { + if(jobs.isEmpty())return; + var args=new ArrayList();args.add(hq);jobs.forEach(j->args.add(id(j,"id"))); + var byJob=new HashMap>(); + for(var row:db.queryForList("SELECT bo_ext_id0,bo_obj_data FROM "+table+" WHERE bo_type=300 AND bo_ext_id1=? AND bo_ext_id0 IN ("+CrmScope.marks(jobs.size())+") ORDER BY bo_id",args.toArray())) { + var item=decode(text(row,"bo_obj_data"));if(item!=null)byJob.computeIfAbsent(id(row,"bo_ext_id0"),k->new ArrayList<>()).add(item); + } + for(var job:jobs)job.put("exportItems",byJob.getOrDefault(id(job,"id"),List.of())); + } + List> rows(long job,long hq,boolean lock) { + String sql="SELECT * FROM "+table+" WHERE bo_ext_id0=? AND bo_ext_id1=? AND bo_type=300 ORDER BY bo_id"; + return lock?db.lockRows(sql,job,hq):db.queryForList(sql,job,hq); + } + void replace(long job,long hq,List> previous,List items,LocalDateTime at) { + for(var row:previous)if(decode(text(row,"bo_obj_data"))!=null) { + if(id(row,"bo_state")!=0||id(row,"bo_ver")!=0||id(row,"bo_ext_id2")!=0)throw AdminSupport.bad("Bereits verarbeitete B2B-Rechnungspositionen können nicht ersetzt werden."); + db.update("DELETE FROM "+table+" WHERE bo_id=? AND bo_ext_id0=? AND bo_ext_id1=? AND bo_type=300",id(row,"bo_id"),job,hq); + } + insert(job,hq,items,at); + } + BatchInvoiceTexts.Item decode(String raw) { + try { + var n=support.json.readTree(raw); + if(!n.isArray()||n.size()!=8||!"c".equals(n.get(0).asText())||!"s".equals(n.get(2).asText())||!"p".equals(n.get(4).asText())||!"t".equals(n.get(6).asText()))return null; + return new BatchInvoiceTexts.Item(new BigDecimal(n.get(1).asText()),n.get(3).asText(),new BigDecimal(n.get(5).asText()),new BigDecimal(n.get(7).asText())); + }catch(Exception e){return null;/* Non-invoice type-300 records are owned by other PHP processes. */} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java new file mode 100644 index 0000000..dd53bef --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceRefresh.java @@ -0,0 +1,156 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.math.*; +import java.time.LocalDateTime; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Rebuilds invoice output from saved list-booking prices, without changing the prices. */ +@RestController +@RequestMapping("/api/orders/{id}/invoice-items") +public class BatchInvoiceRefresh { + private final DatabaseService db;private final BusinessSupport support;private final OrderOperations orders; + private final OrderEntryCatalog catalog;private final BatchInvoiceExports exports; + public BatchInvoiceRefresh(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryCatalog catalog) { + this.db=db;this.support=support;this.orders=orders;this.catalog=catalog;this.exports=new BatchInvoiceExports(db,support); + } + public record Command(String requestId,String revision,String fingerprint,boolean confirmReplace) {} + record Prepared(OrderOperations.Snapshot snapshot,Map customer,List> previous, + BatchInvoiceTexts.Texts texts,boolean writeCourier,String fingerprint) {} + @PostMapping public Object preview(Authentication a,@PathVariable long id,@RequestBody Command c){return preview(ApiController.user(a),id,c);} + @PutMapping public Object save(Authentication a,@PathVariable long id,@RequestBody Command c){return save(ApiController.user(a),id,c);} + + private void access(LegacyUser u){u.require(4);u.require(7);} + private void transactional(){support.transactional("headquarters","job","genericdatacontainer");exports.requireTransactional();} + private Prepared prepare(LegacyUser u,long jobId,Command c,boolean lock) { + if(c==null||c.revision()==null)throw bad("Bitte den Auftrag neu laden."); + var s=orders.snapshot(u,jobId,lock);unchanged(s,c.revision());orders.writable(s); + if(id(s.job(),"jb_origin")!=7)throw bad("Diese Aktualisierung ist für Aufträge aus der Listenerfassung vorgesehen."); + if(id(s.job(),"jb_id_parent")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_parent=? LIMIT 1",jobId).isEmpty())throw bad("Verbundene Unteraufträge benötigen eine gemeinsame Rechnungsaufbereitung."); + var customer=catalog.billingCenter(u,id(s.job(),"csc_id_payer"),false); + var lists=db.queryForList("SELECT bl.jbb_id,bl.jbbl_lfd FROM jobbatchlist bl JOIN jobbatch b ON b.jbb_id=bl.jbb_id WHERE bl.jb_id=? AND b.cs_id=?",jobId,id(customer,"cs_id")); + if(lists.size()!=1)throw bad("Der Auftrag ist keiner eindeutigen Kundenliste zugeordnet."); + var settings=BatchInvoiceTexts.settings(db,catalog,u.headquartersId(),customer); + var tolls=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",jobId); + if(tolls.size()>1)throw bad("Mehrdeutige Mautposition."); + BigDecimal toll=BigDecimal.ZERO; + try{if(!tolls.isEmpty())toll=new BigDecimal(text(tolls.getFirst(),"gdc_content"));}catch(NumberFormatException e){throw bad("Ungültige Mautposition.");} + // checkDiscountStr uses the first nonzero rate from this unsorted PHP query. + // Do not substitute the UI's sorted price rows when their rates differ. + var discountOrder=db.queryForList("SELECT trs_discount FROM tourservice WHERE jb_id=?",jobId); + var texts=fromSaved(s,settings,toll,firstDiscount(discountOrder)); + var rows=exports.rows(jobId,u.headquartersId(),lock); + for(var row:rows)if(exports.decode(text(row,"bo_obj_data"))!=null&&(id(row,"bo_state")!=0||id(row,"bo_ver")!=0||id(row,"bo_ext_id2")!=0))throw bad("Bereits verarbeitete B2B-Rechnungspositionen können nicht ersetzt werden."); + return new Prepared(s,customer,rows,texts,settings.courier(),revision(List.of(s,customer,lists,settings,tolls,rows,texts,discountOrder))); + } + Map preview(LegacyUser u,long id,Command c) { + access(u); + return db.readTransaction(()->{transactional();var p=prepare(u,id,c,false); + return fields("fingerprint",p.fingerprint(),"previous",fields("customer",text(p.snapshot().job(),"jb_invtext"),"courier",text(p.snapshot().job(),"jb_invtext_cr"),"exportItems",p.previous().stream().map(r->exports.decode(text(r,"bo_obj_data"))).filter(Objects::nonNull).toList()),"next",p.texts(),"writeCourier",p.writeCourier(),"warnings",warnings(p.snapshot(),p.texts()));}); + } + Map save(LegacyUser u,long id,Command c) { + access(u);if(c==null)throw bad("Aktualisierungsdaten fehlen.");BusinessSupport.token(c.requestId()); + if(!c.confirmReplace())throw bad("Bitte das Ersetzen der angezeigten Rechnungstexte und Positionen bestätigen."); + transactional(); + db.writeTransaction(()->{lockHeadquarters(db,u);Object receipt=List.of("batch-invoice-refresh",id,c); + if(support.receipt(u,c.requestId(),receipt)!=null)return null; + var p=prepare(u,id,c,true); + if(!p.fingerprint().equals(c.fingerprint()))throw new ResponseStatusException(HttpStatus.CONFLICT,"Auftrag, Rechnungspositionen oder Druckvorgaben haben sich geändert. Bitte die Vorschau erneuern."); + exports.replace(id,u.headquartersId(),p.previous(),p.texts().exportItems(),LocalDateTime.now().withNano(0)); + var values=fields("jb_invtext",p.texts().customer(),"jb_version",id(p.snapshot().job(),"jb_version")+1); + if(p.writeCourier())values.put("jb_invtext_cr",p.texts().courier()); + BusinessSupport.update(db,"job","jb_id",id,values); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_invtext_dirty'",id); + support.audit(u,id,id(p.customer(),"cs_id"),id(p.snapshot().job(),"cr_id"),2,"Rechnungstexte und B2B-Positionen der Listenbuchung aktualisiert",c.requestId()); + support.receipt(u,c.requestId(),receipt,id);return null; + });return orders.view(u,id); + } + static List warnings(OrderOperations.Snapshot snapshot,BatchInvoiceTexts.Texts texts) { + boolean serviceDiscount=snapshot.prices().stream().anyMatch(p->text(p,"trs_srv_name").equals("Servicepreis")&&decimal(p,"trs_discount").signum()!=0); + var warnings=new ArrayList(); + boolean shown=texts.exportItems().stream().anyMatch(i->i.description().equals("Rabatt")); + if(serviceDiscount&&shown)warnings.add("Der bisherige Rechnungsausweis berücksichtigt den rabattierten Servicepreis in der Rabattzeile, verwendet dort aber nur den Fuhrpreis als Basis. Der ausgewiesene Rabattbetrag kann deshalb vom rechnerischen Preisnachlass abweichen. Bitte die Vorschau prüfen; die gespeicherte Auftragssumme wird nicht geändert."); + long rates=snapshot.prices().stream().map(p->decimal(p,"trs_discount").stripTrailingZeros()).filter(r->r.signum()!=0).distinct().count(); + if(shown&&rates>1)warnings.add("Fuhr- und Servicepreis haben unterschiedliche Rabattsätze. Wie in PHP weist die gemeinsame Rabattzeile nur den Satz der ersten gelesenen rabattierten Preisposition aus. Die Einzelpositionen verwenden ihre jeweiligen Rabatte; die Auftragssumme bleibt unverändert."); + return List.copyOf(warnings); + } + /** One fixed freight position and optional service, with validated saved discounts. */ + static BigDecimal firstDiscount(List> prices){return prices.stream().map(p->decimal(p,"trs_discount")).filter(r->r.signum()!=0).findFirst().orElse(BigDecimal.ZERO);} + static BatchInvoiceTexts.Texts fromSaved(OrderOperations.Snapshot s,BatchInvoiceTexts.Settings settings,BigDecimal toll) { + return fromSaved(s,settings,toll,firstDiscount(s.prices())); + } + static BatchInvoiceTexts.Texts fromSaved(OrderOperations.Snapshot s,BatchInvoiceTexts.Settings settings,BigDecimal toll,BigDecimal firstDiscount) { + var j=s.job(); + String calculator=text(j,"jb_freetext_2").strip(); + if(id(j,"jb_payment")!=0||!(calculator.isBlank()||calculator.equals("²²²"))) + throw bad("Diese Listenbuchung enthält eine Sonderkalkulation. Bitte die Rechnungsaufbereitung in PHP durchführen."); + BigDecimal selfRate=BigDecimal.ZERO,selfGross=null;boolean hasSelf=false; + for(var p:s.priceDetails()) { + if(id(p,"mt_sort")==13) { + if(hasSelf)throw bad("Mehrdeutige Selbsterfasserrabatt-Metadaten."); + hasSelf=true; + String rawRate=text(p,"jbprc_price"); + if(rawRate.isBlank())throw bad("Ungültiger Selbsterfasserrabatt."); + BigDecimal rate; + try{rate=new BigDecimal(rawRate);}catch(NumberFormatException e){throw bad("Ungültiger Selbsterfasserrabatt.");} + if(rate.signum()==0) { + selfRate=BigDecimal.ZERO; + } else { + if(rate.compareTo(BigDecimal.ZERO)<=0||rate.compareTo(new BigDecimal("100"))>=0)throw bad("Ungültiger Selbsterfasserrabatt."); + selfRate=rate; + String remark=text(p,"jbprc_remark"); + if(remark.isBlank())throw bad("Ungültige Selbsterfasserrabatt-Metadaten."); + BigDecimal parsedGross; + try{parsedGross=new BigDecimal(remark);}catch(NumberFormatException e){throw bad("Ungültige Selbsterfasserrabatt-Metadaten.");} + if(parsedGross.signum()<=0)throw bad("Ungültige Selbsterfasserrabatt-Metadaten."); + if(parsedGross.stripTrailingZeros().scale()>2)throw bad("Ungültige Selbsterfasserrabatt-Metadaten."); + selfGross=parsedGross; + } + } + } + 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()) { + String name=text(p,"trs_srv_name"); + if(id(p,"csc_id")!=id(j,"csc_id_payer")||!text(p,"trs_srvt_name").isBlank()||!Set.of("Fixpreis","Fixpreis (manuell)","Servicepreis").contains(name))throw bad("Diese Listenbuchung enthält zusätzliche Tarif- oder Leistungspositionen. Bitte die Rechnungsaufbereitung in PHP durchführen."); + BigDecimal amount=decimal(p,"trs_price"),d=decimal(p,"trs_discount"); + if(amount==null||d==null||amount.signum()<0||d.signum()<0||d.compareTo(BigDecimal.valueOf(100))>0)throw bad("Ungültige gespeicherte Preisposition."); + if(name.equals("Servicepreis")){if(hasService)throw bad("Mehrdeutige Serviceposition.");hasService=true;service=amount;serviceDiscount=d;} + else{if(hasFreight)throw bad("Mehrdeutige Fuhrpreisposition.");hasFreight=true;freight=amount;discount=d;freightName=name;} + } + 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) { + String markupRaw=text(j,"jb_markup"); + if(markupRaw.isBlank())throw bad("Ungültiger Zuschlag."); + BigDecimal markupVal; + try{markupVal=new BigDecimal(markupRaw);}catch(NumberFormatException e){throw bad("Ungültiger Zuschlag.");} + if(markupVal.compareTo(new BigDecimal("-100"))<=0)throw bad("Ungültiger Zuschlag."); + if(!hasFreight||!freightName.equals("Fixpreis"))throw bad("Die gespeicherte Fuhrpreisposition passt nicht zum Selbsterfasserrabatt."); + BigDecimal deduction=selfGross.multiply(selfRate.movePointLeft(2)).setScale(2,RoundingMode.HALF_UP); + BigDecimal expected=selfGross.subtract(deduction); + if(expected.compareTo(freight)!=0)throw bad("Die gespeicherte Fuhrpreisposition passt nicht zum Selbsterfasserrabatt."); + } + StringBuilder base=new StringBuilder(text(j,"jb_freetext_1")); + if(!base.isEmpty()&&base.charAt(base.length()-1)!='\n')base.append('\n'); + if(toll.signum()!=0)base.append("Maut: ").append(toll.setScale(2,RoundingMode.HALF_UP).toPlainString().replace('.',',')).append(" EUR\n"); + for(var stop:s.stops())if(!text(stop,"tr_commission_no").isBlank())base.append("# ").append(text(stop,"tr_commission_no")).append('\n'); + 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) { + BigDecimal deduction=selfGross.multiply(selfRate.movePointLeft(2)).setScale(2,RoundingMode.HALF_UP); + self=new BatchInvoiceTexts.SelfDiscount(selfRate,selfGross,deduction); + } + var result=BatchInvoiceTexts.create(row,amounts,settings,freightName,self,OrderInvoiceNotes.value(s.invoiceNotes()),serviceDiscount,firstDiscount); + return new BatchInvoiceTexts.Texts(result.customer(),settings.courier()?result.courier():text(j,"jb_invtext_cr"),result.exportItems()); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/BatchInvoiceTexts.java b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceTexts.java new file mode 100644 index 0000000..caf9b39 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/BatchInvoiceTexts.java @@ -0,0 +1,128 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; + +/** List-booking branch of the current server's mk_jb_invtext/checkDiscountStr. */ +final class BatchInvoiceTexts { + record Settings(int columns,int lines,int exportColumns,boolean omitBlankLines,boolean discount, + boolean markup,boolean service,boolean euro,boolean courier,boolean services) { + Settings(int columns,int lines,int exportColumns,boolean omitBlankLines,boolean discount,boolean markup,boolean service,boolean euro,boolean courier){this(columns,lines,exportColumns,omitBlankLines,discount,markup,service,euro,courier,false);} + } + record Item(BigDecimal factor,String description,BigDecimal price,BigDecimal total) { + List legacy(){return List.of("c",number(factor),"s",description,"p",number(price),"t",number(total));} + private static String number(BigDecimal n){return n.round(new MathContext(10,RoundingMode.HALF_UP)).stripTrailingZeros().toPlainString();} + } + record Texts(String customer,String courier,List exportItems) {} + record SelfDiscount(BigDecimal rate,BigDecimal gross,BigDecimal deduction) {} + static Settings settings(DatabaseService db,OrderEntryCatalog catalog,long hq,Map customerData) { + long customer=LegacyMapping.id(customerData,"cs_id"); + boolean service=false; + if(catalog.enabled(hq,"INV_SHOW_MARKUP_BATCH")) { + var rows=db.queryForList("SELECT par_key,md_id,hq_id,emp_id,par_value FROM parameter WHERE par_key=?",ExtrasCustomerSettings.priceKey(customer)); + service=catalog.enabled(hq,"INV_PRINT_SRVPRICE") | Boolean.TRUE.equals(ExtrasCustomerSettings.decode(ExtrasCustomerSettings.Type.SERVICE_PRICE,customer,rows).servicePrice()); + } + return new Settings(limit(catalog,hq,"INV_MAXCOLS",80,1,500),limit(catalog,hq,"INV_MAXLINES",20,1,200), + limit(catalog,hq,"INV_MAXCOLS_EXPORT",0,0,500),catalog.enabled(hq,"INV_PRINT_REMARK"), + catalog.enabled(hq,"INV_PRINT_DISCOUNT"),printMarkup(catalog.parameter(hq,"MASK_MARKUP_MODE")),service, + catalog.enabled(hq,"INV_USE_EURO_SIGN"),catalog.enabled(hq,"INV_JB_INVOICE_CR"), + LegacyMapping.id(customerData,"cs_invmode")!=0&&globalFlag(db,"MASK_INVOICE_SHOW_SERVICES_"+customer)); + } + static boolean printMarkup(String raw){ + if(raw==null||raw.isBlank())return false; + try{return new BigDecimal(raw).signum()!=0;}catch(NumberFormatException e){throw bad("MASK_MARKUP_MODE: Ungültige Zuschlagsvorgabe.");} + } + static boolean globalFlag(DatabaseService db,String key) { + var rows=db.queryForList("SELECT md_id,hq_id,emp_id,par_value FROM parameter WHERE par_key=?",key); + if(rows.isEmpty())return false; + if(rows.size()!=1)throw bad(key+": Mehrdeutige Kundenvorgabe."); + var r=rows.getFirst();String value=LegacyMapping.text(r,"par_value"); + if(LegacyMapping.id(r,"md_id")!=0||LegacyMapping.id(r,"hq_id")!=0||LegacyMapping.id(r,"emp_id")!=0||!Set.of("0","1").contains(value))throw bad(key+": Ungültige globale Kundenvorgabe."); + return value.equals("1"); + } + private static int limit(OrderEntryCatalog c,long hq,String key,int fallback,int min,int max) { + String raw=c.parameter(hq,key);if(raw.isBlank())return fallback; + try{int n=Integer.parseInt(raw);if(n>=min&&n<=max)return n;}catch(NumberFormatException ignored){} + throw bad(key+": Ungültige Rechnungsformatierung ("+min+"–"+max+")."); + } + static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s) { + return create(row,a,s,"Fixpreis",null); + } + static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName) { + return create(row,a,s,freightName,null); + } + static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self) { + return create(row,a,s,freightName,self,""); + } + static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self,String invoiceOnlyText) { + return create(row,a,s,freightName,self,invoiceOnlyText,BigDecimal.ZERO); + } + static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self,String invoiceOnlyText,BigDecimal serviceDiscount) { + return create(row,a,s,freightName,self,invoiceOnlyText,serviceDiscount,a.discount().signum()!=0?a.discount():serviceDiscount); + } + static Texts create(OrderBatches.Row row,LegacyOrderPricing.BatchAmounts a,Settings s,String freightName,SelfDiscount self,String invoiceOnlyText,BigDecimal serviceDiscount,BigDecimal discountRate) { + String base=row.text().endsWith("\n")?row.text():row.text()+"\n",text=base,currency=s.euro()?"€":"EUR"; + if(!invoiceOnlyText.isBlank())text+=invoiceOnlyText+(invoiceOnlyText.endsWith("\n")?"":"\n"); + BigDecimal freight=a.freightWithMarkup().setScale(4,RoundingMode.HALF_UP),rounded=money(freight); + var items=new ArrayList(); + // PHP's getJobCosts branch includes these rows only for non-PZM customers + // with the customer-specific detailed-service setting. They are already discounted. + if(s.services()) { + if(freight.signum()!=0){BigDecimal amount=freight.multiply(BigDecimal.ONE.subtract(a.discount().movePointLeft(2)));items.add(new Item(BigDecimal.ONE,freightName,amount,amount));text+=freightName+": "+decimal(amount)+" EUR\n";} + if(row.service().signum()!=0){BigDecimal amount=row.service().multiply(BigDecimal.ONE.subtract(serviceDiscount.movePointLeft(2)));items.add(new Item(BigDecimal.ONE,"Servicepreis",amount,amount));text+="Servicepreis: "+decimal(amount)+" EUR\n";} + if(!items.isEmpty())text=text.stripTrailing(); + } + if(s.markup()&&a.markup().signum()!=0) { + BigDecimal divisor=BigDecimal.ONE.add(a.markup().movePointLeft(2)); + if(divisor.signum()==0)throw bad("Rechnungstext: Zuschlag -100 % ist nicht berechenbar."); + BigDecimal thatGross=(self!=null)?self.gross():rounded; + BigDecimal without=thatGross.divide(divisor,2,RoundingMode.HALF_UP); + BigDecimal total=thatGross.subtract(without); + items.add(new Item(a.markup().movePointLeft(2),"Treibstoffzuschlag",without,total)); + text+="\nTreibstoffzuschlag "+decimal(a.markup())+"% auf "+decimal(without)+" "+currency+" = "+decimal(total)+" "+currency; + } + if(self!=null&&self.rate().signum()!=0) { + BigDecimal markup=a.markup(); + if(markup.signum()<0&&markup.compareTo(new BigDecimal("-100"))<=0)throw bad("Rechnungstext: Zuschlag -100 % ist nicht berechenbar."); + items.add(new Item(self.rate().movePointLeft(2),"Selbsterfasserrabatt",self.gross(),self.deduction().negate())); + text+="\nSelbsterfasserrabatt "+decimal(self.rate())+"% auf "+decimal(self.gross())+" "+currency+" = "+decimal(self.deduction().negate())+" "+currency; + } + // PHP checkDiscountStr sums rounded (price * (1 + discount)) for every + // discounted row, including service, but subtracts that sum from freight only. + // This legacy display is not the actual price deduction; preview explains it. + if(s.discount()&&discountRate.signum()!=0&&(freight.signum()!=0||serviceDiscount.signum()!=0)) { + BigDecimal withDiscount=a.discount().signum()!=0?money(freight.multiply(BigDecimal.ONE.add(a.discount().movePointLeft(2)))):BigDecimal.ZERO; + if(serviceDiscount.signum()!=0)withDiscount=withDiscount.add(money(row.service().multiply(BigDecimal.ONE.add(serviceDiscount.movePointLeft(2))))); + items.add(new Item(discountRate.movePointLeft(2),"Rabatt",freight,freight.subtract(withDiscount))); + text+="\nRabatt "+decimal(discountRate)+"% auf "+decimal(freight)+" "+currency+" = "+decimal(freight.subtract(withDiscount))+" "+currency; + } + if(s.service()&&row.service().signum()!=0){text+="\nServicepreis "+decimal(row.service())+" "+currency;items.add(new Item(BigDecimal.ONE,"Servicepreis",row.service(),row.service()));} + try{return new Texts(format(text,s),s.courier()?format(base,s):"",List.copyOf(items));} + catch(org.springframework.web.server.ResponseStatusException e){throw bad("Zeile "+row.line()+": "+e.getReason());} + } + private static BigDecimal money(BigDecimal n){return n.setScale(2,RoundingMode.HALF_UP);} + private static String decimal(BigDecimal n){return money(n).toPlainString().replace('.',',');} + static String format(String text,Settings s) { + var lines=new ArrayList(); + for(String line:text.replace("\r","").split("\n",-1))if(!s.omitBlankLines()||!line.isBlank())lines.addAll(wrap(line,s)); + // As in mkLineBreaksWithBreaks, preserve explicit breaks if they fit; otherwise compact them. + if(lines.size()+1>s.lines())lines=new ArrayList<>(wrap(text.strip().replace("\r","").replaceAll("\n+","; "),s)); + if(lines.size()>s.lines())throw bad("Der Rechnungstext überschreitet "+s.lines()+" Zeilen zu "+s.columns()+" Zeichen. Bitte Tourenverlauf kürzen oder Rechnungsparameter anpassen."); + while(lines.size()65535)throw bad("Der formatierte Rechnungstext ist zu groß. Bitte Exportbreite oder Zeilenanzahl verkleinern."); + return formatted; + } + private static List wrap(String text,Settings s) { + var result=new ArrayList();String rest=text.strip(); + while(rest.codePointCount(0,rest.length())>s.columns()) { + int end=rest.offsetByCodePoints(0,s.columns());int space=rest.lastIndexOf(' ',end); + if(space<=0)throw bad("Ein Wort im Rechnungstext überschreitet "+s.columns()+" Zeichen. Bitte einen Zeilenumbruch oder ein Leerzeichen einfügen."); + result.add(pad(rest.substring(0,space),s));rest=rest.substring(space+1); + } + result.add(pad(rest,s));return result; + } + private static String pad(String s,Settings settings){return s+" ".repeat(Math.max(0,settings.exportColumns()-s.codePointCount(0,s.length())));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/BillingService.java b/backend/src/main/java/de/stadtbote/votian/BillingService.java index 1de7fe0..4012902 100644 --- a/backend/src/main/java/de/stadtbote/votian/BillingService.java +++ b/backend/src/main/java/de/stadtbote/votian/BillingService.java @@ -15,27 +15,39 @@ import static de.stadtbote.votian.BusinessSupport.*; public class BillingService { final DatabaseService db;final BusinessSupport support; public BillingService(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} - public record Query(LocalDate from,LocalDate to,String mode,Long customer,Long center,boolean descendants,Long courier,String invoice,boolean unassigned){} - public record Assign(String requestId,Query query,String revision,List orders,String number,LocalDate date){} - record Assignment(long id,String revision){} + public record Query(LocalDate from,LocalDate to,String mode,Long customer,Long center,boolean descendants,Long courier,String invoice,boolean unassigned,List vehicleTypes){ + public Query(LocalDate from,LocalDate to,String mode,Long customer,Long center,boolean descendants,Long courier,String invoice,boolean unassigned){this(from,to,mode,customer,center,descendants,courier,invoice,unassigned,List.of());} + boolean association(){return "INVOICE".equals(mode);} + } + public record Assign(String requestId,Query query,String revision,List orders,String number,LocalDate date,boolean replaceExisting){ + public Assign(String requestId,Query query,String revision,List orders,String number,LocalDate date){this(requestId,query,revision,orders,number,date,false);} + } + record InvoiceValue(String number,String date){} + record Assignment(long id,String revision,InvoiceValue previous){} record Operation(long user,long hq,String number,String date,List items,String hash){} static BigDecimal nz(BigDecimal v){return v==null?BigDecimal.ZERO:v;} static BigDecimal money(BigDecimal v){return v.setScale(2,RoundingMode.HALF_UP);} static BigDecimal net(Map p){return money(nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2));} - void check(LegacyUser u,Query q){u.require(5);if(q==null)throw bad("Abrechnungsfilter fehlen.");range(q.from(),q.to());if(q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.mode()==null||!Set.of("CUSTOMER","COURIER").contains(q.mode()))throw bad("Ungültiger Abrechnungsbereich.");} - public Map options(LegacyUser u){u.require(5);return fields("customers",db.queryForList("SELECT cs.cs_id AS id,CONCAT(cs.cs_eid,' · ',c.cmp_comp) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"couriers",db.queryForList("SELECT cr.cr_id AS id,CONCAT(cr.cr_eid,' · ',c.cmp_comp) AS label FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"canAssign",u.hasRight(7));} + void check(LegacyUser u,Query q){u.require(5);if(q==null)throw bad("Abrechnungsfilter fehlen.");range(q.from(),q.to());if(q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.mode()==null||!Set.of("CUSTOMER","COURIER","INVOICE").contains(q.mode()))throw bad("Ungültiger Abrechnungsbereich."); + if(q.vehicleTypes()!=null&&(q.vehicleTypes().size()>50||q.vehicleTypes().stream().anyMatch(v->v==null||v<0||v>999)||new HashSet<>(q.vehicleTypes()).size()!=q.vehicleTypes().size()))throw bad("Ungültiger Fahrzeugfilter."); + if(q.association()&&q.customer()==null)throw bad("Für die Rechnungszuordnung bitte einen Kunden auswählen."); + } + public Map options(LegacyUser u){u.require(5);return fields("customers",db.queryForList("SELECT cs.cs_id AS id,CONCAT(cs.cs_eid,' · ',c.cmp_comp) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"couriers",db.queryForList("SELECT cr.cr_id AS id,CONCAT(cr.cr_eid,' · ',c.cmp_comp) AS label FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"vehicleTypes",db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort ORDER BY mt_sort"),"canAssign",u.hasRight(7));} public List> centers(LegacyUser u,long customer){u.require(5);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));return db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name",customer);} public Map query(LegacyUser u,Query q){check(u,q);return db.readTransaction(()->statement(u,q));} Map statement(LegacyUser u,Query q){ Set centers=new HashSet<>();if(q.center()!=null){var cs=one(db.queryForList("SELECT c.csc_id,c.cs_id FROM costcenter c JOIN customer s ON s.cs_id=c.cs_id WHERE c.csc_id=? AND s.hq_id=?",q.center(),u.headquartersId()));if(q.customer()!=null&&q.customer()!=id(cs,"cs_id"))throw bad("Die Kostenstelle gehört zu einem anderen Kunden.");centers.add(q.center());if(q.descendants()){var all=db.queryForList("SELECT csc_id,csc_pre_id FROM costcenter WHERE cs_id=?",id(cs,"cs_id"));boolean added;do{added=false;for(var r:all)if(centers.contains(id(r,"csc_pre_id")))added|=centers.add(id(r,"csc_id"));}while(added);}} - String sql="SELECT j.*,i.inv_no,i.inv_datetime,c.cs_id,c.csc_name,cs.cs_eid,cmp.cmp_comp FROM job j LEFT JOIN invoice i ON i.jb_id=j.jb_id LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer LEFT JOIN customer cs ON cs.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE j.hq_id=? AND j.jb_status=2 AND COALESCE(j.jb_offer,0)=0 AND j.jb_finishtime>=? AND j.jb_finishtime=? AND "+(q.association()?"j.jb_ordertime":"j.jb_finishtime")+"(List.of(u.headquartersId(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay())); + if(q.association())sql+=" AND j.jb_export_time IS NOT NULL"; + if(q.vehicleTypes()!=null&&!q.vehicleTypes().isEmpty()){sql+=" AND j.vht_id IN ("+String.join(",",Collections.nCopies(q.vehicleTypes().size(),"?"))+")";args.addAll(q.vehicleTypes());} if(q.mode().equals("CUSTOMER"))sql+=" AND j.jb_payment<>2 AND j.csc_id_payer<>-1"; - if(q.customer()!=null){sql+=" AND (c.cs_id=? OR EXISTS (SELECT 1 FROM tourservice t JOIN costcenter tc ON tc.csc_id=t.csc_id WHERE t.jb_id=j.jb_id AND tc.cs_id=?))";args.add(q.customer());args.add(q.customer());} + if(q.association()){sql+=" AND c.cs_id=?";args.add(q.customer());} + else if(q.customer()!=null){sql+=" AND (c.cs_id=? OR EXISTS (SELECT 1 FROM tourservice t JOIN costcenter tc ON tc.csc_id=t.csc_id WHERE t.jb_id=j.jb_id AND tc.cs_id=?))";args.add(q.customer());args.add(q.customer());} if(q.courier()!=null){sql+=" AND j.cr_id=?";args.add(q.courier());} if(q.invoice()!=null&&!q.invoice().isBlank()){sql+=" AND i.inv_no=?";args.add(q.invoice().strip());} if(q.unassigned())sql+=" AND i.jb_id IS NULL"; - var jobs=db.queryForList(sql+" ORDER BY j.jb_finishtime,j.jb_id LIMIT 2001",args.toArray());if(jobs.size()>2000)throw bad("Mehr als 2.000 Aufträge gefunden. Bitte den Zeitraum oder den Kundenfilter eingrenzen."); + var jobs=db.queryForList(sql+(q.association()?" ORDER BY j.jb_id":" ORDER BY j.jb_finishtime,j.jb_id")+" LIMIT 2001",args.toArray());if(jobs.size()>2000)throw bad("Mehr als 2.000 Aufträge gefunden. Bitte den Zeitraum oder den Kundenfilter eingrenzen."); var rows=new ArrayList>();var revisionData=new ArrayList();revisionData.add(jobs); for(var j:jobs){long job=id(j,"jb_id");var prices=db.queryForList("SELECT t.*,c.cs_id,c.csc_name,s.cs_eid,cmp.cmp_comp FROM tourservice t LEFT JOIN costcenter c ON c.csc_id=t.csc_id LEFT JOIN customer s ON s.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=s.cmp_id WHERE t.jb_id=? ORDER BY t.csc_id,t.tr_sort,t.srv_id,t.srvt_id",job);revisionData.add(prices); var tollRows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job);revisionData.add(tollRows);if(tollRows.size()>1)throw bad("Auftrag #"+job+": Maut ist mehrfach hinterlegt.");BigDecimal toll=tollRows.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(tollRows.getFirst(),"gdc_content")); @@ -57,21 +69,23 @@ public class BillingService { BigDecimal courierNet(Map job,List> prices){ var catalog=new OrderEntryCatalog(db);long hq=id(job,"hq_id");var vehicle=db.queryForList("SELECT crvh_prov FROM couriervehicle WHERE crvh_sid=?",text(job,"cr_sid"));BigDecimal commission=vehicle.isEmpty()?BigDecimal.ZERO:nz(decimal(one(vehicle),"crvh_prov")); 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&&!from.isBlank()){try{after=at.isAfter(LocalDate.parse(from.substring(0,10)).atStartOfDay());}catch(RuntimeException ignored){throw bad("MASK_CR_PRICE_MODE_DATE ist ungültig.");}} + 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); } - 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"));} - 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||new HashSet<>(a.orders()).size()!=a.orders().size())throw bad("Bitte Rechnungsdatum und eindeutige Aufträge auswählen.");if(!a.query().mode().equals("CUSTOMER")||a.query().customer()==null)throw bad("Rechnungszuordnungen benötigen genau einen ausgewählten Kunden."); + 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")&&!(Boolean)row.get("exported")&&text(row,"invoice").isBlank())eligible.add(id(row,"id")); - var items=new ArrayList();for(long job:a.orders()){if(!eligible.contains(job))throw bad("Auftrag #"+job+" ist unfertig, bereits zugeordnet, exportiert oder gehört nicht zur Auswahl.");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.");if(!db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",job).isEmpty())throw bad("Auftrag bereits zugeordnet.");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)));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"));} + 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 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. db.insert("genericdatacontainer",fields("gdc_obj_type","nginv","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",a.requestId(),"gdc_content",support.encode(new Operation(u.userId(),u.headquartersId(),number,a.date().toString(),items,hash)),"gdc_context","PENDING"));return null;}); return resume(u,a.requestId()); } + InvoiceValue invoiceValue(long job){var rows=db.queryForList("SELECT inv_no,CAST(inv_datetime AS CHAR) AS ng_invoice_date FROM invoice WHERE jb_id=?",job);if(rows.size()>1)throw bad("Auftrag #"+job+": mehrere Rechnungszuordnungen vorhanden.");return rows.isEmpty()?null:new InvoiceValue((String)rows.getFirst().get("inv_no"),(String)rows.getFirst().get("ng_invoice_date"));} String assignmentRevision(long job){var j=new LinkedHashMap<>(one(db.queryForList("SELECT * FROM job WHERE jb_id=?",job)));j.remove("jb_locktime");j.remove("jb_lockuser");j.remove("jb_modify");return revision(List.of(j,db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY csc_id,tr_sort,srv_id,srvt_id",job),db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job)));} Map operation(String token){var rows=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_gen_fieldname=?",token(token));return rows.isEmpty()?null:one(rows);} Operation decode(Map row){try{return support.json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw new IllegalStateException(e);}} @@ -79,9 +93,16 @@ public class BillingService { for(var item:op.items()){ one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",item.id(),u.headquartersId())); if(!item.revision().equals(assignmentRevision(item.id())))throw bad("Auftrag #"+item.id()+" wurde seit Beginn der Zuordnung geändert. Der Vorgang bleibt zur Prüfung offen."); - // Single-row compare-and-insert preserves PHP changes and makes retries safe. - db.update("INSERT INTO invoice (jb_id,inv_no,inv_datetime) SELECT ?,?,? WHERE NOT EXISTS (SELECT jb_id FROM invoice WHERE jb_id=?)",item.id(),op.number(),LocalDate.parse(op.date()).atTime(12,0),item.id()); - var stored=one(db.queryForList("SELECT inv_no,inv_datetime FROM invoice WHERE jb_id=?",item.id()));if(!text(stored,"inv_no").equals(op.number())||!dateTime(stored,"inv_datetime").toLocalDate().equals(LocalDate.parse(op.date())))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen."); + var target=new InvoiceValue(op.number(),LocalDate.parse(op.date()).atTime(12,0).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + var before=invoiceValue(item.id()); + if(!Objects.equals(before,target)){ + if(!Objects.equals(before,item.previous()))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen."); + // MyISAM writes survive a rollback. Compare the complete old value in the same statement; + // after an uncertain response an already matching target is a completed item. + if(item.previous()==null)db.update("INSERT INTO invoice (jb_id,inv_no,inv_datetime) SELECT ?,?,? WHERE NOT EXISTS (SELECT jb_id FROM invoice WHERE jb_id=?)",item.id(),op.number(),target.date(),item.id()); + else db.update("UPDATE invoice SET inv_no=?,inv_datetime=? WHERE jb_id=? AND BINARY inv_no <=> BINARY ? AND CAST(inv_datetime AS CHAR) <=> ?",op.number(),target.date(),item.id(),item.previous().number(),item.previous().date()); + } + if(!Objects.equals(invoiceValue(item.id()),target))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen."); } 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());});} diff --git a/backend/src/main/java/de/stadtbote/votian/BusinessController.java b/backend/src/main/java/de/stadtbote/votian/BusinessController.java index 7387f75..5d9f4bc 100644 --- a/backend/src/main/java/de/stadtbote/votian/BusinessController.java +++ b/backend/src/main/java/de/stadtbote/votian/BusinessController.java @@ -12,6 +12,11 @@ public class BusinessController { public BusinessController(MasterData masters,OrderOperations orders,BillingService billing){this.masters=masters;this.orders=orders;this.billing=billing;} @GetMapping("/master-data/{kind}/{id}") public Object master(Authentication a,@PathVariable String kind,@PathVariable long id,@RequestParam(defaultValue="0")long parent){return masters.view(ApiController.user(a),kind,id,parent);} @PutMapping("/master-data/{kind}/{id}") public Object saveMaster(Authentication a,@PathVariable String kind,@PathVariable long id,@RequestParam(defaultValue="0")long parent,@RequestBody MasterData.Save value){return masters.save(ApiController.user(a),kind,id,parent,value);} + @GetMapping("/master-data/vehicles/{id}/copy") public Object vehicleCopy(Authentication a,@PathVariable long id){return new VehicleCopy(masters).view(ApiController.user(a),id);} + @GetMapping("/master-data/vehicles/{id}/copy/draft") public Object vehicleCopyDraft(Authentication a,@PathVariable long id,@RequestParam long headquarters,@RequestParam String eid){return new VehicleCopy(masters).draft(ApiController.user(a),id,headquarters,eid);} + @PostMapping("/master-data/vehicles/{id}/copy") public Object saveVehicleCopy(Authentication a,@PathVariable long id,@RequestBody VehicleCopy.Save value){return new VehicleCopy(masters).save(ApiController.user(a),id,value);} + @GetMapping("/master-data/customers/{id}/copy") public Object copyCustomer(Authentication a,@PathVariable long id){return new CustomerCopy(masters).draft(ApiController.user(a),id);} + @GetMapping("/master-data/{kind}/identifier") public Object identifier(Authentication a,@PathVariable String kind){return masters.suggestIdentifier(ApiController.user(a),kind);} @GetMapping("/orders/{id}/workbench") public Object order(Authentication a,@PathVariable long id){return orders.view(ApiController.user(a),id);} @PutMapping("/orders/{id}/workbench") public Object edit(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Edit value){return orders.edit(ApiController.user(a),id,value);} @PutMapping("/orders/{id}/price") public Object price(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Price value){return orders.price(ApiController.user(a),id,value);} diff --git a/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java b/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java index 3a70fcc..27c1e01 100644 --- a/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java +++ b/backend/src/main/java/de/stadtbote/votian/BusinessSupport.java @@ -19,7 +19,9 @@ public class BusinessSupport { public BusinessSupport(DatabaseService db,ObjectMapper json){this(db,json,"phoenix_log");} @org.springframework.beans.factory.annotation.Autowired public BusinessSupport(DatabaseService db,ObjectMapper json,@org.springframework.beans.factory.annotation.Value("${app.history-schema:phoenix_log}") String schema){this.db=db;this.json=json;if(!schema.matches("[A-Za-z0-9_]+"))throw new IllegalArgumentException("Ungültiges Historienschema");this.historySchema="`"+schema+"`";} - record Audit(long headquarters,long user,long job,long customer,long courier,int operation,String description,String at){} + record Audit(long headquarters,long user,long job,long customer,long courier,long employee,int operation,String description,String at,long article){ + Audit(long headquarters,long user,long job,long customer,long courier,long employee,int operation,String description,String at){this(headquarters,user,job,customer,courier,employee,operation,description,at,0);} + } String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} static String token(String value){try{String normalized=UUID.fromString(value).toString();if(!normalized.equals(value))throw new IllegalArgumentException();return normalized;}catch(Exception e){throw bad("Die Vorgangskennung fehlt. Bitte die Ansicht neu öffnen.");}} Long receipt(LegacyUser user,String token,Object request){ @@ -30,7 +32,15 @@ public class BusinessSupport { } void receipt(LegacyUser user,String token,Object request,long result){db.insert("genericdatacontainer",fields("gdc_obj_type","ngop","gdc_obj_id",user.userId(),"gdc_gen_fieldname",token(token),"gdc_content",Long.toString(result),"gdc_context",revision(List.of(user.headquartersId(),request))));} void audit(LegacyUser user,long job,long customer,long courier,int operation,String description,String token){ - var event=new Audit(user.headquartersId(),user.userId(),job,customer,courier,operation,description+" | NG="+token,LocalDateTime.now().withNano(0).toString()); + audit(user,job,customer,courier,0,operation,description,token); + } + void auditEmployee(LegacyUser user,long employee,int operation,String description,String token){audit(user,0,0,0,employee,operation,description,token);} + void audit(LegacyUser user,long job,long customer,long courier,long employee,int operation,String description,String token){ + var event=new Audit(user.headquartersId(),user.userId(),job,customer,courier,employee,operation,description+" | NG="+token,LocalDateTime.now().withNano(0).toString()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngaudit","gdc_obj_id",user.headquartersId(),"gdc_gen_fieldname",token,"gdc_content",encode(event),"gdc_context","PENDING")); + } + void auditArticle(LegacyUser user,long article,int operation,String description,String token){ + var event=new Audit(user.headquartersId(),user.userId(),0,0,0,0,operation,description+" | NG="+token,LocalDateTime.now().withNano(0).toString(),article); db.insert("genericdatacontainer",fields("gdc_obj_type","ngaudit","gdc_obj_id",user.headquartersId(),"gdc_gen_fieldname",token,"gdc_content",encode(event),"gdc_context","PENDING")); } @Scheduled(initialDelay=60000,fixedDelay=30000) @@ -39,10 +49,16 @@ public class BusinessSupport { db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_context='PENDING'",id(pending,"gdc_id"));if(rows.isEmpty())return null; Audit e;try{e=json.readValue(text(rows.getFirst(),"gdc_content"),Audit.class);}catch(Exception ex){throw new IllegalStateException(ex);} if(db.queryForList("SELECT log_id FROM "+historySchema+".log WHERE hq_id=? AND usr_id=? AND logo_description=? LIMIT 1",e.headquarters(),e.user(),e.description()).isEmpty()) - db.update("INSERT INTO "+historySchema+".log (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,?,?,?,'',?,0,0,0,?,?)",e.operation(),e.headquarters(),e.job(),e.user(),e.courier(),e.customer(),e.description(),LocalDateTime.parse(e.at())); + db.update("INSERT INTO "+historySchema+".log (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,?,?,?,'',?,?,0,?,?,?)",e.operation(),e.headquarters(),e.job(),e.user(),e.courier(),e.customer(),e.article(),employeeId(e),e.description(),LocalDateTime.parse(e.at())); db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=?",id(pending,"gdc_id"));return null;}); }}catch(RuntimeException ignored){/* Durable pending events remain available for the next run. */} } + static long employeeId(Audit e){ + if(e.employee()>0)return e.employee(); + // Earlier NG employee events already carried an exact prefix, before emp_id was added. + if(Set.of(73,74,75,76,115,116,184).contains(e.operation())&&e.description()!=null){var m=java.util.regex.Pattern.compile("^EMPLOYEE=([1-9][0-9]*) \\|").matcher(e.description());if(m.find())try{return Long.parseLong(m.group(1));}catch(NumberFormatException ignored){}} + return 0; + } void transactional(String... tables) { for(String table:tables){String[] parts=table.split("\\.");String schema=parts.length==2?parts[0]:null;String name=parts[parts.length-1]; Integer count=db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA="+(schema==null?"DATABASE()":"?")+" AND TABLE_NAME=? AND ENGINE='InnoDB'",Integer.class,schema==null?new Object[]{name}:new Object[]{schema,name}); diff --git a/backend/src/main/java/de/stadtbote/votian/CallCenterAccess.java b/backend/src/main/java/de/stadtbote/votian/CallCenterAccess.java new file mode 100644 index 0000000..d2f5f28 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CallCenterAccess.java @@ -0,0 +1,45 @@ +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; + +/** PHP's global CC24 employee list; separate from ordinary HQ permission vectors. */ +final class CallCenterAccess { + static final String EMPLOYEES="HEADQUARTERS_MULTIPLE_ACCESS_EMPLOYEES"; + static final String GLOBAL_JOBS="HEADQUARTERS_MULTIPLE_ACCESS_EMPLOYEES_GLOBALJOBS"; + static final String FOREIGN_MASK="1110100111000000000000000000000000000000000000000000000000000000000000"; + private CallCenterAccess() {} + static List> rows(DatabaseService db,String key){ + var rows=db.queryForList("SELECT par_id,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key=? ORDER BY par_id",key); + if(rows.size()>1)throw bad("Der CC24-Parameter ist mehrfach vorhanden. Bitte den Bestand zuerst klären."); + if(!rows.isEmpty())ids(text(rows.getFirst(),"par_value")); + return rows; + } + static String value(List> rows){return rows.isEmpty()?"":text(rows.getFirst(),"par_value");} + static Set ids(String value){ + if(value.length()>65535)throw bad("Der CC24-Parameter ist zu lang."); + var ids=new LinkedHashSet(); + for(String part:value.split("\\|",-1)){ + String token=part.strip();if(token.isEmpty()||token.equals("0"))continue; + if(!token.matches("[1-9][0-9]{0,9}"))throw bad("Der CC24-Parameter enthält eine ungültige Kennung."); + ids.add(Long.parseLong(token)); + } + return ids; + } + static boolean enabled(DatabaseService db,LegacyUser user){return ids(value(rows(db,EMPLOYEES))).contains(user.employeeId());} + static boolean entrySpecial(DatabaseService db,LegacyUser user){return user.employeeId()==300001||enabled(db,user);} + static boolean allCouriers(DatabaseService db,LegacyUser user){ + if(!entrySpecial(db,user))return true; + String configured=value(rows(db,GLOBAL_JOBS)); + return configured.isBlank()||ids(configured).contains(user.headquartersId()); + } + static String effective(LegacyUser user,String live){ + var result=new StringBuilder();for(int i=0;i KEYS=Set.of("jbpc_id","cmp_no","currentdate","bookingdate","jbpc_mode","price","jbpc_invtext","cs_eid1","cs_eid2","invtext","freetext_1","freetext_2","freetext_3","postage","tax_rate","tax_rate_sign","cr_price","subtotalprice","markup","cr_subprice","const_901","const_902","const_903","const_904","const_905","VAT_special","vht_id","prod_descr","price_special"); + record Definition(MasterExports.State state,Settings settings) {} + static String key(String expression){ + String key=expression.replaceFirst("^(inv|jbpc)\\.",""); + if(!KEYS.contains(key))throw bad("Das Einnahmenexportfeld „"+expression+"“ benötigt einen Abgleich mit der PHP-Felddefinition."); + return key; + } + static Definition definition(CashExportPlan plan,LegacyUser u,long profile){ + if(profile<=0)throw bad("Bitte ein Einnahmenexportprofil auswählen."); + var db=plan.db; + if(db.queryForList("SELECT expc_id FROM exportcategory WHERE expc_id=9 AND expc_usrtype=1 AND expc_type=0").size()!=1)throw bad("Die Felddefinition der Exportkategorie 9 fehlt."); + var definitions=db.queryForList("SELECT expcf_id,expcf_field,expcf_field_sql,expcf_title FROM exportcategoryfields WHERE expc_id=9 ORDER BY expcf_id LIMIT 301"); + if(definitions.isEmpty()||definitions.size()>300)throw bad("Die Felddefinition der Exportkategorie 9 fehlt oder ist zu umfangreich."); + var fields=new ArrayList();var ids=new HashSet(); + for(var row:definitions){String name=text(row,"expcf_field"),key=key(name);if(!name.equals(text(row,"expcf_field_sql"))||!ids.add(id(row,"expcf_id")))throw bad("Die Einnahmenexport-Felddefinition benötigt einen aktualisierten Abgleich.");fields.add(new Field(id(row,"expcf_id"),key,text(row,"expcf_title"),key.equals("price")?"float":"text"));} + var rows=db.queryForList("SELECT * FROM exportparameters WHERE expp_id=? AND expc_id=9 AND hq_id=? AND cs_id=0",profile,u.headquartersId()); + if(rows.size()!=1)throw bad("Das Einnahmenexportprofil ist für diese Niederlassung nicht vorhanden."); + var state=new MasterExports.State(u.headquartersId(),fields,List.of(),rows.getFirst());var settings=MasterExports.settings(state);validate(settings,fields);return new Definition(state,settings); + } + static Map constants(CashExportPlan plan,long hq,Map customer){ + var out=new LinkedHashMap();String hqKey=plan.catalog.parameter(hq,"EXPORT_HQ_KEY");out.put("cmp_no",(hqKey.isBlank()?plan.catalog.parameter(0,"EXPORT_HQ_KEY"):hqKey)+" "); + long group=OrderPayments.group(customer); + for(int n=1;n<=5;n++){String parameter=String.format(Locale.ROOT,"EXPORT_CAT_09_CONST_%02d",n);String v=plan.object(0,parameter+"_CS",id(customer,"cs_id"));if(v.isEmpty()&&group>0)v=plan.object(hq,parameter,group);out.put("const_90"+n,v);} + for(var item:Map.of("VAT_special","VAT_SPECIAL","vht_id","VHT_ID","prod_descr","PRODUCT","price_special","PRICE_SPECIAL").entrySet())out.put(item.getKey(),plan.catalog.parameter(0,"EXPORT_CAT_09_CONST_"+item.getValue())); + return out; + } + static List> rows(List> collections,Map customer,Map constants,LocalDate day){ + var result=new ArrayList>();for(var c:collections){var row=new LinkedHashMap();KEYS.forEach(k->row.put(k,""));row.putAll(constants);row.put("currentdate",day.format(DateTimeFormatter.BASIC_ISO_DATE));String date=text(c,"jbpc_bookingdate");try{row.put("bookingdate",LocalDate.parse(date.substring(0,10)).format(DateTimeFormatter.BASIC_ISO_DATE));}catch(Exception e){throw bad("Eine Zahlungssammlung hat kein gültiges Buchungsdatum.");}row.put("jbpc_id",id(c,"jbpc_id"));row.put("jbpc_mode",id(c,"jbpc_mode"));row.put("price",Cashflow.net(c.get("jbpc_price")));row.put("jbpc_invtext",text(c,"jbpc_invtext"));row.put("cs_eid1",text(customer,"cs_eid"));row.put("cs_eid2",text(customer,"cs_eid"));result.add(row);}return result; + } + static byte[] render(Definition d,List> rows){return MasterExportFormat.bytes(MasterExportFormat.render(d.settings(),d.state().fields(),rows,"|",false,null).replace("|"+" ".repeat(70),"|"));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java b/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java new file mode 100644 index 0000000..06196e4 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CashExportPlan.java @@ -0,0 +1,49 @@ +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.BigDecimal; +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; + +/** The current PHP category-9 collection selection, without creating collections during preview. */ +@RestController @RequestMapping("/api/cashflow/export-plan") +public final class CashExportPlan { + record Input(Cashflow.Query query,int paymentFilter) {} + record Group(LocalDate date,int counter,int mode,int installation,String invoice,List payments,BigDecimal net) {} + record Key(LocalDate date,int counter,int mode,int installation) implements Comparable { + public int compareTo(Key other){int c=date.compareTo(other.date);if(c==0)c=Integer.compare(counter,other.counter);if(c==0)c=Integer.compare(installation,other.installation);return c==0?Integer.compare(mode,other.mode):c;} + } + final Cashflow cash;final DatabaseService db;final OrderEntryCatalog catalog; + public CashExportPlan(Cashflow cash){this.cash=cash;db=cash.db;catalog=cash.catalog;} + @PostMapping Map preview(Authentication a,@RequestBody Input i){return preview(ApiController.user(a),i);} + String object(long hq,String key,long object){for(var item:List.of(Map.entry(hq,key+"_"+object),Map.entry(0L,key+"_"+object),Map.entry(hq,key),Map.entry(0L,key))){String value=catalog.parameter(item.getKey(),item.getValue());if(!value.isEmpty())return value;}return "";} + String setting(long hq,Map customer,String key){String value=object(hq,key+"_CS",id(customer,"cs_id"));long group=OrderPayments.group(customer);return value.isEmpty()&&group>0?object(hq,key+"_GRP",group):value;} + static List modes(String value){if(value==null||!value.strip().matches("[0-9]{1,3}(?:\\s*,\\s*[0-9]{1,3}){0,127}"))throw bad("Für diesen Kunden fehlt eine gültige Auswahl der Export-Zahlarten.");var result=Arrays.stream(value.strip().split("\\s*,\\s*")).map(Integer::valueOf).distinct().sorted().toList();if(result.stream().anyMatch(v->v>127))throw bad("Die Export-Zahlarten liegen außerhalb des gültigen Bereichs.");return result;} + static boolean special(List modes){return modes.stream().anyMatch(Set.of(11,15,16,17,18,19)::contains);} + static List group(List> payments,List modes,Map texts,LocalDate end){ + boolean special=special(modes),split=modes.contains(15);var groups=new TreeMap>>(); + for(var p:payments){var booked=dateTime(p,"jbp_bookingtime");int mode=(int)id(p,"jbp_mode"),counter=(int)id(p,"jbp_counter");if(!modes.contains(mode)||!CashflowActions.open(p)||Cashflow.net(p.get("jbp_price")).signum()==0)continue;if(booked==null)throw bad("Eine Zahlung besitzt kein gültiges Buchungsdatum."); + if(special&&(id(p,"jb_status")!=2||id(p,"jb_incomplete")!=0))continue;if(!special&&counter!=0&&counter!=1)continue; + var key=new Key(special?end:booked.toLocalDate(),special?-1:counter,mode,split?((id(p,"jb_service")&2)==2?1:0):-1);groups.computeIfAbsent(key,k->new ArrayList<>()).add(p); + } + var result=new ArrayList();for(var item:groups.entrySet()){var k=item.getKey();String invoice=split?"Wir berechnen Ihnen für die SAMS-"+(k.installation()==0?"Lieferungen":"Montagen")+" gemäß beiliegender|Anlage bis einschließlich":texts.getOrDefault(k.mode(),"");result.add(new Group(k.date(),k.counter(),k.mode(),k.installation(),invoice,item.getValue().stream().map(p->id(p,"jbp_id")).sorted().toList(),Cashflow.sum(item.getValue(),"jbp_price")));}return result; + } + Map preview(LegacyUser original,Input input){return db.readTransaction(()->{ + original.require(6);if(input==null||input.query()==null||input.paymentFilter()<1||input.paymentFilter()>99)throw bad("Bitte eine gültige Einnahmenexport-Auswahl wählen.");var q=cash.check(original,input.query());if(q.customer()<=0)throw bad("Für den Einnahmenexport bitte einen einzelnen Kunden auswählen.");var u=cash.scope(original,q.headquarters());var customer=cash.customer(u,q.customer());var selected=modes(setting(u.headquartersId(),customer,String.format(Locale.ROOT,"EXPORT_CAT_09_JBPMODES_%02d",input.paymentFilter())));var texts=new TreeMap();for(int mode:selected)texts.put(mode,setting(u.headquartersId(),customer,"EXPORT_CAT_09_PAYMENTTYPE_"+mode)); + // PHP uses the payment's cost centre, which may differ from the order payer. + var args=new ArrayList(List.of(q.customer(),u.headquartersId(),Cashflow.start(q),Cashflow.end(q)));args.addAll(selected); + var payments=db.queryForList("SELECT p.*,j.jb_status,j.jb_incomplete,j.jb_service FROM jobpayment p JOIN costcenter cc ON cc.csc_id=p.csc_id JOIN job j ON j.jb_id=p.jb_id WHERE cc.cs_id=? AND j.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') AND p.jbp_mode IN ("+CrmScope.marks(selected.size())+") ORDER BY p.jbp_id LIMIT 10001",args.toArray());Cashflow.bound(payments,10000);var groups=group(payments,selected,texts,q.to()); + // Already prepared collections remain export candidates even outside the selected booking period. + var collectionArgs=new ArrayList(List.of(q.customer()));collectionArgs.addAll(selected); + var collections=db.queryForList("SELECT cashCollection.* FROM jobpaymentcollection cashCollection WHERE cashCollection.cs_id=? AND (cashCollection.jbpc_export_time IS NULL OR cashCollection.jbpc_export_time='0000-00-00 00:00:00') AND cashCollection.jbpc_mode IN ("+CrmScope.marks(selected.size())+") AND EXISTS (SELECT 1 FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id WHERE p.jbpc_id=cashCollection.jbpc_id) ORDER BY cashCollection.jbpc_id LIMIT 2001",collectionArgs.toArray());Cashflow.bound(collections,2000); + 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))); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CashExportRuns.java b/backend/src/main/java/de/stadtbote/votian/CashExportRuns.java new file mode 100644 index 0000000..7aa2f43 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CashExportRuns.java @@ -0,0 +1,82 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Collections and payload are reserved atomically; export flags follow durable file installation. */ +@RestController @RequestMapping("/api/cashflow/export-runs") +final class CashExportRuns { + record Input(CashExportPlan.Input selection,long profile,String filename) {} + record Command(String requestId,Input input,String revision,boolean confirmed) {} + record Operation(String requestId,long actor,long headquarters,Command command,List created,List collections,String dataRevision,String digest,int bytes,ExportEncryption.Policy encryption,String status,long file,LocalDateTime at) { + String name(){return encryption.filename(command.input().filename());} + String physical(){return "ngcash-"+requestId+encryption.suffix();} + } + record Snapshot(Map plan,CashExportFormat.Definition definition,Map customer,Map constants,LocalDate day,byte[] preview,String revision) {} + record Data(List> collections,List> payments) {} + final CashExportPlan plan;final DatabaseService db;final BusinessSupport support;final ExportFileStore files; + CashExportRuns(CashExportPlan plan,ExportFileStore files){this.plan=plan;this.db=plan.db;this.support=plan.cash.support;this.files=files;} + LegacyUser scope(LegacyUser original,long hq){original.require(6);return plan.cash.scope(original,hq);} + @PostMapping("/preview") Object preview(Authentication a,@RequestBody Input input){return preview(ApiController.user(a),input);} + @PostMapping Object start(Authentication a,@RequestBody Command command){return start(ApiController.user(a),command);} + @GetMapping Object operations(Authentication a,@RequestParam long headquarters){return operations(ApiController.user(a),headquarters);} + @PostMapping("/{requestId}/resume") Object resume(Authentication a,@PathVariable String requestId,@RequestParam long headquarters){return resume(ApiController.user(a),headquarters,requestId);} + @PostMapping("/{requestId}/cancel") Object cancel(Authentication a,@PathVariable String requestId,@RequestParam long headquarters){return cancel(ApiController.user(a),headquarters,requestId);} + @SuppressWarnings("unchecked") Snapshot snapshot(LegacyUser original,Input input){ + if(input==null||input.selection()==null||input.selection().query()==null)throw bad("Die Exportauswahl fehlt.");MasterExports.filename(input.filename()); + var p=plan.preview(original,input.selection());var q=(Cashflow.Query)p.get("query");var u=scope(original,q.headquarters());var customer=plan.cash.customer(u,q.customer());var definition=CashExportFormat.definition(plan,u,input.profile());var constants=CashExportFormat.constants(plan,u.headquartersId(),customer);var day=LocalDate.now(ZoneId.of("Europe/Berlin")); + var collections=new ArrayList>();for(var c:(List>)p.get("existing"))collections.add(fields("jbpc_id",c.get("id"),"jbpc_mode",c.get("mode"),"jbpc_price",c.get("net"),"jbpc_bookingdate",c.get("date"),"jbpc_invtext",c.get("invoice"))); + for(var g:(List)p.get("groups"))collections.add(collection(q.customer(),0,g)); + if(collections.isEmpty())throw bad("Keine offenen Zahlungssammlungen für diesen Export.");Cashflow.bound(collections,2000); + byte[] preview=CashExportFormat.render(definition,CashExportFormat.rows(collections,customer,constants,day)); + return new Snapshot(p,definition,customer,constants,day,preview,revision(List.of(input,p.get("revision"),definition,customer,constants,day))); + } + static Map collection(long customer,long id,CashExportPlan.Group g){return fields("jbpc_id",id,"cs_id",customer,"jbpc_mode",g.mode(),"jbpc_price",g.net(),"jbpc_bookingdate",g.date(),"jbpc_invtext",g.invoice());} + Map preview(LegacyUser original,Input input){return db.readTransaction(()->{var s=snapshot(original,input);var u=scope(original,((Cashflow.Query)s.plan().get("query")).headquarters());boolean canStore=true;String notice="Neue Sammelnummern werden beim Start vergeben und erscheinen in der Dateivorschau als 0.";try{writable(u);}catch(org.springframework.web.server.ResponseStatusException e){canStore=false;notice+=" "+e.getReason();}String content=new String(s.preview(),java.nio.charset.Charset.forName("windows-1252"));return fields("revision",s.revision(),"bytes",s.preview().length,"content",content.substring(0,Math.min(20000,content.length())),"truncated",content.length()>20000,"filename",input.filename(),"canStore",canStore,"notice",notice);});} + void writable(LegacyUser u){u.require(6);if(!files.store.configured())throw bad("Die gemeinsame Exportablage ist noch nicht eingerichtet.");String path=plan.catalog.parameter(0,"EXPORT_PATH");if(!path.isBlank()&&!path.replaceAll("/+$","").equals("../export/download"))throw bad("Die konfigurierte Exportablage muss abgeglichen werden.");files.encryption.policy(db,u);} + 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().input()==null||op.collections().isEmpty()||op.collections().size()>2000||!op.collections().containsAll(op.created())||op.bytes()<1||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 gespeicherte Einnahmenexport ist unvollständig.");}} + Operation find(LegacyUser u,String requestId){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' 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();plan.cash.check(u,op.command().input().selection().query());return op;} + Map operations(LegacyUser original,long hq){return db.readTransaction(()->{var u=scope(original,hq);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC LIMIT 101",u.headquartersId());Cashflow.bound(rows,100);return fields("operations",rows.stream().map(this::decode).filter(o->o.actor()==u.userId()).map(o->fields("requestId",o.requestId(),"filename",o.name(),"customer",o.command().input().selection().query().customer(),"collections",o.collections().size())).toList());});} + void free(LegacyUser u,Command command,String name){ + 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='ngcashrun' AND gdc_context='PENDING'")){var op=decode(row);if(op.requestId().equals(command.requestId()))continue;if(op.command().input().selection().query().customer()==command.input().selection().query().customer()||op.headquarters()==u.headquartersId()&&op.name().equalsIgnoreCase(name))throw bad("Für diesen Kunden oder Dateinamen ist bereits ein Einnahmenexport offen. Bitte zuerst wiederaufnehmen oder verwerfen.");} + } + @SuppressWarnings("unchecked") Map start(LegacyUser original,Command command){ + if(command==null||!command.confirmed()||command.input()==null||command.input().selection()==null||command.input().selection().query()==null)throw bad("Bitte die Dateierstellung und Exportkennzeichnung bestätigen.");token(command.requestId());var u=scope(original,command.input().selection().query().headquarters()); + var prior=db.readTransaction(()->find(u,command.requestId()));if(prior!=null){if(!prior.command().equals(command))throw bad("Diese Vorgangskennung wurde bereits anders verwendet.");return resume(original,u.headquartersId(),command.requestId());} + db.cashExportWriteTransaction(()->{ + var existing=find(u,command.requestId());if(existing!=null){if(!existing.command().equals(command))throw bad("Diese Vorgangskennung wurde bereits anders verwendet.");return null;} + writable(u);var s=snapshot(original,command.input());if(!s.revision().equals(command.revision()))throw bad("Die Vorschau ist nicht mehr aktuell. Bitte erneut prüfen.");var policy=files.encryption.policy(db,u);free(u,command,policy.filename(command.input().filename())); + var created=new ArrayList();var ids=new TreeSet();for(var c:(List>)s.plan().get("existing"))ids.add(id(c,"id")); + for(var group:(List)s.plan().get("groups")){var c=collection(command.input().selection().query().customer(),0,group);c.remove("jbpc_id");long key=db.insert("jobpaymentcollection",c);created.add(key);ids.add(key);for(long payment:group.payments())if(db.update("UPDATE jobpayment SET jbpc_id=? WHERE jbp_id=? AND jbpc_id=0 AND (jbp_export_time IS NULL OR jbp_export_time='0000-00-00 00:00:00')",key,payment)!=1)throw bad("Eine Zahlung wurde zwischenzeitlich gesammelt oder exportiert.");} + var data=data(u,new ArrayList<>(ids),command.input().selection().query().customer());byte[] payload=files.encryption.encrypt(CashExportFormat.render(s.definition(),CashExportFormat.rows(data.collections(),s.customer(),s.constants(),s.day())),policy); + var op=new Operation(command.requestId(),u.userId(),u.headquartersId(),command,created,new ArrayList<>(ids),revision(data),DocumentStore.digest(payload),payload.length,policy,"PENDING",0,null);String encoded=support.encode(op);if(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Sammlungen für einen Einnahmenexport.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngcashrun","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",command.requestId(),"gdc_content",encoded,"gdc_context","PENDING")); + String text=Base64.getEncoder().encodeToString(payload);for(int offset=0,part=0;offset ids,long customer){ + var collections=new ArrayList>();var payments=new ArrayList>(); + for(int i=0;iid(c,"cs_id")!=customer||dateTime(c,"jbpc_export_time")!=null)||payments.stream().anyMatch(p->id(p,"hq_id")!=u.headquartersId()||id(p,"cs_id")!=customer||OrderPayments.exportedPayment(p)))throw bad("Die Zahlungssammlungen wurden geändert oder sind nicht mehr für diesen Export freigegeben."); + for(var c:collections)if(payments.stream().noneMatch(p->id(p,"jbpc_id")==id(c,"jbpc_id")))throw bad("Eine Zahlungssammlung besitzt keine Zahlungen mehr."); + collections.sort(Comparator.comparingLong(c->id(c,"jbpc_id")));payments.sort(Comparator.comparingLong(p->id(p,"jbp_id")));return new Data(collections,payments); + } + byte[] payload(Operation op){var text=new StringBuilder();for(var row:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!' ORDER BY gdc_context",op.headquarters(),op.requestId()+"!_%")){text.append(text(row,"gdc_content"));if(text.length()>13_333_336)throw bad("Die gespeicherte Exportdatei ist zu groß.");}byte[] bytes;try{bytes=Base64.getDecoder().decode(text.toString());}catch(Exception e){throw bad("Die Exportdatei ist unvollständig.");}if(bytes.length!=op.bytes()||!DocumentStore.digest(bytes).equals(op.digest()))throw bad("Die Exportdatei wurde verändert oder unvollständig gespeichert.");return bytes;} + void store(Operation op){var bytes=payload(op);try(var fs=files.store.open()){String stage=".ng-staging/"+op.requestId()+".part";if(fs.exists(op.physical())){if(!DocumentStore.digest(fs.read(op.physical(),CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Exportdatei wurde verändert.");}else{fs.mkdir(".ng-staging");if(fs.exists(stage)&&!DocumentStore.digest(fs.read(stage,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))fs.remove(stage);if(!fs.exists(stage))fs.create(stage,bytes);fs.move(stage,op.physical());}if(fs.exists(stage))fs.remove(stage);}} + void update(Operation op){db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_obj_type='ngcashrun' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(op),op.status().equals("DONE")?"LOG_PENDING":op.status(),op.headquarters(),op.requestId());} + void clear(Operation op){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngcashpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",op.headquarters(),op.requestId()+"!_%");} + Map resume(LegacyUser original,long headquarters,String requestId){var u=scope(original,headquarters);var result=db.cashExportWriteTransaction(()->{ + var op=find(u,requestId);if(op==null)throw bad("Einnahmenexport nicht gefunden.");if(op.status().equals("DONE"))return fields("completed",true,"file",op.file(),"replayed",true);if(!op.status().equals("PENDING"))throw bad("Der Einnahmenexport wurde verworfen.");writable(u);files.encryption.unchanged(db,u,op.encryption());free(u,op.command(),op.name());var data=data(u,op.collections(),op.command().input().selection().query().customer());if(!revision(data).equals(op.dataRevision()))throw bad("Die gesammelten Zahlungen wurden nach der Vorbereitung geändert. Bitte die Zuordnung klären."); + store(op);var at=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);for(long collection:op.collections()){db.update("UPDATE jobpayment SET jbp_export_time=? WHERE jbpc_id=?",at,collection);db.update("UPDATE jobpaymentcollection SET jbpc_export_time=? WHERE jbpc_id=?",at,collection);}long file=db.insert("exportfiles",fields("hq_id",u.headquartersId(),"cs_id",0,"expf_name",op.name(),"expf_cryptname",op.physical(),"expf_storetime",at,"expf_ftpupload",0));update(new Operation(op.requestId(),op.actor(),op.headquarters(),op.command(),op.created(),op.collections(),op.dataRevision(),op.digest(),op.bytes(),op.encryption(),"DONE",file,at));clear(op);support.audit(u,0,op.command().input().selection().query().customer(),0,203,"CASH_EXPORT="+file+"|COLLECTIONS="+op.collections().size()+"|PAYMENTS="+data.payments().size()+"|SHA256="+op.digest(),op.requestId());return fields("completed",true,"file",file,"collections",op.collections().size(),"payments",data.payments().size()); + });publishLogs();return result;} + Map cancel(LegacyUser original,long headquarters,String requestId){var u=scope(original,headquarters);return db.cashExportWriteTransaction(()->{var op=find(u,requestId);if(op==null)throw bad("Einnahmenexport nicht gefunden.");if(op.status().equals("CANCELLED"))return fields("cancelled",true);if(!op.status().equals("PENDING"))throw bad("Ein abgeschlossener Export kann nicht verworfen werden.");if(!revision(data(u,op.collections(),op.command().input().selection().query().customer())).equals(op.dataRevision()))throw bad("Die gesammelten Zahlungen wurden geändert. Bitte die Zuordnung vor dem Verwerfen klären.");if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=?",op.physical()).isEmpty())throw bad("Die Datei wurde bereits veröffentlicht.");try(var fs=files.store.open()){if(fs.exists(op.physical())){if(!DocumentStore.digest(fs.read(op.physical(),CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Exportdatei wurde verändert.");fs.remove(op.physical());}String stage=".ng-staging/"+op.requestId()+".part";if(fs.exists(stage))fs.remove(stage);}for(long key:op.created()){db.update("UPDATE jobpayment SET jbpc_id=0 WHERE jbpc_id=?",key);db.update("DELETE FROM jobpaymentcollection WHERE jbpc_id=?",key);}update(new Operation(op.requestId(),op.actor(),op.headquarters(),op.command(),op.created(),op.collections(),op.dataRevision(),op.digest(),op.bytes(),op.encryption(),"CANCELLED",0,null));clear(op);return fields("cancelled",true);});} + @org.springframework.scheduling.annotation.Scheduled(initialDelay=60000,fixedDelay=30000) + public void publishLogs(){try{for(var row:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngcashrun' AND gdc_context='LOG_PENDING' ORDER BY gdc_id LIMIT 30"))db.writeTransaction(()->{long key=id(row,"gdc_id");var rows=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_context='LOG_PENDING'",key);if(rows.isEmpty())return null;var op=decode(one(rows));if(!op.status().equals("DONE")||op.at()==null)throw bad("Ungültiger Exportprotokollauftrag.");String ids=String.join(",",op.collections().stream().map(String::valueOf).toList());if(db.queryForList("SELECT exl_id FROM exportlog WHERE expc_id=9 AND exl_rows=? AND exl_exporttime=? AND usr_id=? LIMIT 1",ids,op.at(),op.actor()).isEmpty())db.insert("exportlog",fields("expc_id",9,"exl_rows",ids,"exl_exporttime",op.at(),"usr_id",op.actor()));db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",key);return null;});}catch(RuntimeException ignored){/* The durable export log is retried without repeating the export. */}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/Cashflow.java b/backend/src/main/java/de/stadtbote/votian/Cashflow.java new file mode 100644 index 0000000..9b7e034 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/Cashflow.java @@ -0,0 +1,70 @@ +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 static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Kasse: the five current statistic_cashflow.php views and their shared selection rules. */ +@RestController +@RequestMapping("/api/cashflow") +public final class Cashflow { + final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;final OrderPayments paymentCatalog; + public Cashflow(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,OrderPayments paymentCatalog){this.db=db;this.support=support;this.catalog=catalog;this.paymentCatalog=paymentCatalog;} + record Query(int view,long headquarters,long customer,LocalDate from,LocalDate to,LocalTime fromTime,LocalTime toTime,int counter,int service,Integer mode,boolean calculator,boolean articles){} + record Job(Map job,List> payments,List> calculation,List> articles,List> metadata){} + record Data(Query query,List> rows,List> dayTotals,Map jobs,List> modes,String customer,boolean canEdit){} + 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);} + 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"));} + Map customer(LegacyUser u,long customer){return one(db.queryForList("SELECT cs.cs_id,cs.cs_eid,cs.cs_group,c.cmp_comp FROM customer cs LEFT JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=?",customer,u.headquartersId()));} + List> modes(Map customer){List> modes=List.of();if(!customer.isEmpty()){modes=paymentCatalog.choices("cs",id(customer,"cs_id"));long group=OrderPayments.group(customer);if(modes.isEmpty()&&group>0)modes=paymentCatalog.choices("grp",group);}return modes.isEmpty()?paymentCatalog.choices("",0):modes;} + static String label(List> modes,long id){return modes.stream().filter(r->id(r,"mt_sort")==id).map(r->text(r,"mt_value")).findFirst().orElse("Zahlart #"+id);} + Query check(LegacyUser original,Query q){if(q==null||q.view()<0||q.view()>4||q.counter()<1||q.counter()>2||q.service()<0||q.service()>2||q.customer()<0||q.mode()!=null&&(q.mode()<0||q.mode()>127))throw bad("Ungültige Kassenauswahl.");var u=scope(original,q.headquarters());range(q.from(),q.to());if(!u.hasRight(8)){if(!q.from().equals(q.to()))throw bad("Ohne Statistikrecht ist nur ein einzelner Kassentag freigegeben.");if(q.customer()==0||q.customer()!=defaultCustomer(u))throw bad("Für dieses Konto ist nur der hinterlegte Startkunde freigegeben.");}if(q.view()==0&&q.customer()==0)throw bad("Für die Kassenübersicht bitte einen Kunden auswählen.");if(q.customer()>0)customer(u,q.customer());return new Query(q.view(),u.headquartersId(),q.customer(),q.from(),q.to(),q.fromTime()==null?LocalTime.MIN:q.fromTime().withSecond(0).withNano(0),q.toTime()==null?LocalTime.of(23,59,59):q.toTime().withSecond(59).withNano(0),q.view()==0?1:q.counter(),q.view()==0?0:q.service(),q.mode(),q.calculator(),q.articles());} + static LocalDateTime start(Query q){return q.from().atTime(q.fromTime());}static LocalDateTime end(Query q){return q.to().atTime(q.toTime()).plusSeconds(1);} + @GetMapping("/options") Map options(Authentication a,@RequestParam(required=false) Long headquarters){return options(ApiController.user(a),headquarters);} + Map options(LegacyUser original,Long selected){var u=scope(original,selected==null?original.headquartersId():selected);long preset=defaultCustomer(u);var customers=db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',NULLIF(cs.cs_eid,''),NULLIF(c.cmp_comp,'')) AS label FROM customer cs LEFT JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=?"+(u.hasRight(8)?"":" AND cs.cs_id=?")+" ORDER BY c.cmp_comp,cs.cs_id",u.hasRight(8)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),preset});return fields("headquarters",headquarters(original),"currentHeadquarters",u.headquartersId(),"customers",customers,"defaultCustomer",customers.stream().anyMatch(c->id(c,"id")==preset)?preset:0,"canChooseCustomer",u.hasRight(8),"canChoosePeriod",u.hasRight(8),"canEdit",u.hasRight(7),"canExport",u.hasRight(6),"canOpenOrders",u.hasRight(4)&&u.headquartersId()==original.headquartersId(),"views",java.util.stream.IntStream.range(0,5).mapToObj(i->fields("id",i,"label",VIEWS.get(i))).toList());} + @PostMapping("/query") Map query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + Map query(LegacyUser original,Query raw){return db.readTransaction(()->view(read(original,raw)));} + Data read(LegacyUser original,Query raw){Query q=check(original,raw);if(!start(q).isBefore(end(q)))throw bad("Der Beginn muss vor dem Ende des Zeitraums liegen.");var u=scope(original,q.headquarters());var cs=q.customer()>0?customer(u,q.customer()):Map.of();var modes=modes(cs);var jobs=new LinkedHashMap(); + String where="j.hq_id=? AND cs.hq_id=?";var args=new ArrayList(List.of(u.headquartersId(),u.headquartersId()));if(q.customer()>0){where+=" AND cs.cs_id=?";args.add(q.customer());} + String base=" FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_related JOIN customer cs ON cs.cs_id=cc.cs_id WHERE "+where; + if(q.view()==0){String filter=" AND j.jb_createtime>=? AND j.jb_createtime0)";var values=new ArrayList<>(args);values.add(start(q));values.add(end(q));var selected=db.queryForList("SELECT j.*"+base+filter+" ORDER BY j.jb_id LIMIT 2001",values.toArray());bound(selected,2000);for(var row:selected)jobs.put(id(row,"jb_id"),job(row,q)); + if(!jobs.isEmpty()){var values2=new ArrayList<>(args);values2.addAll(jobs.keySet());var children=db.queryForList("SELECT j.*"+base+" AND j.jb_id_parent IN ("+CrmScope.marks(jobs.size())+") ORDER BY j.jb_id LIMIT 2001",values2.toArray());bound(children,2000);for(var child:children)if(!jobs.containsKey(id(child,"jb_id")))jobs.put(id(child,"jb_id"),job(child,q));} + }else{ + var values=new ArrayList<>(args);String filter=" AND EXISTS(SELECT 1 FROM jobpayment p WHERE p.jb_id=j.jb_id AND p.csc_id=j.csc_id_related AND p.jbp_bookingtime>=? AND p.jbp_bookingtime0){filter+=" AND (j.jb_service & ?)=?";values.add(q.service());values.add(q.service());} + var selected=db.queryForList("SELECT j.*"+base+filter+" ORDER BY j.jb_id LIMIT 2001",values.toArray());bound(selected,2000);for(var row:selected)jobs.put(id(row,"jb_id"),job(row,q)); + } + var rows=q.view()==0?overview(jobs,modes):statistics(q,jobs,modes); + if(q.view()==4&&q.counter()==2){var values=new ArrayList<>(args);values.add(start(q));values.add(end(q));var target=one(db.queryForList("SELECT COALESCE(SUM(j.jb_totalprice),0) AS expected"+base+" AND j.jb_ordertime>=? AND j.jb_ordertime>();if(q.view()==0){var values=new ArrayList<>(args);values.add(start(q));values.add(end(q));for(var row:db.queryForList("SELECT p.jbp_mode,SUM(p.jbp_price) AS amount FROM jobpayment p JOIN job j ON j.jb_id=p.jb_id JOIN costcenter cc ON cc.csc_id=j.csc_id_related JOIN customer cs ON cs.cs_id=cc.cs_id WHERE "+where+" AND p.csc_id=j.csc_id_related AND p.jbp_counter=1 AND p.jbp_bookingtime>=? AND p.jbp_bookingtime0){var j=jobs.get(number).job();r.put("displayNumber",q.view()==0?(id(j,"jb_id_parent")>0?id(j,"jb_id_parent")+"/2":number+"/1"):OrderDetailOptions.displayNumber(catalog,j));}} + return new Data(q,rows,dayTotals,jobs,modes,cs.isEmpty()?"Alle Kunden":text(cs,"cs_eid")+" · "+text(cs,"cmp_comp"),u.hasRight(7)); + } + static void bound(List rows,int maximum){if(rows.size()>maximum)throw bad("Zu viele Kassenpositionen. Bitte Zeitraum oder Kunde eingrenzen.");} + Job job(Map row,Query q){long id=id(row,"jb_id");var payments=db.queryForList("SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_bookingtime,jbp_id LIMIT 2001",id);bound(payments,2000);return new Job(row,payments,q.calculator()?db.queryForList("SELECT srv_name,srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id=? ORDER BY srv_name,srvt_name",id):List.of(),q.articles()?db.queryForList("SELECT trat_name,trat_quantity,trat_price,trat_serialno,trat_remark FROM tourarticle WHERE jb_id=? ORDER BY trat_sort,trat_id",id):List.of(),db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_service_job','jb_id_addmont_parent','jb_search') ORDER BY gdc_id",id));} + static boolean service(Job job){return job.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("jb_service_job")&&text(m,"gdc_content").equals("1"));} + static Map row(Job job,List> payments,String label){var j=job.job();return fields("job",id(j,"jb_id"),"parent",id(j,"jb_id_parent"),"installation",(id(j,"jb_service")&2)==2,"label",label,"paid",gross(sum(payments,"jbp_price")),"due",gross(net(j.get("jb_subtotalprice"))),"remaining",gross(net(j.get("jb_totalprice")).subtract(sum(job.payments(),"jbp_price"))),"booked",payments.size()==1?text(payments.getFirst(),"jbp_bookingtime"):"","payments",payments.stream().map(p->fields("id",id(p,"jbp_id"),"mode",id(p,"jbp_mode"),"net",net(p.get("jbp_price")),"gross",gross(net(p.get("jbp_price"))),"counter",id(p,"jbp_counter")==1,"collection",id(p,"jbpc_id"),"booked",text(p,"jbp_bookingtime"),"exported",OrderPayments.exportedPayment(p)?text(p,"jbp_export_time"):"")).toList(),"collections",payments.stream().map(p->id(p,"jbpc_id")).filter(v->v>0).distinct().sorted().toList(),"calculation",job.calculation(),"articles",job.articles(),"reference",job.metadata().stream().filter(m->text(m,"gdc_gen_fieldname").equals("jb_search")).map(m->text(m,"gdc_content")).findFirst().orElse(""));} + static List> overview(Map jobs,List> modes){var rows=new ArrayList>();for(var j:jobs.values()){var payments=j.payments().stream().filter(p->id(p,"jbp_counter")==1).toList();var r=row(j,payments,payments.stream().map(p->label(modes,id(p,"jbp_mode"))).distinct().reduce((a,b)->a+" / "+b).orElse("Ohne Kassenzahlung"));r.put("allPayments",row(j,j.payments(),"").get("payments"));rows.add(r);}return rows;} + static boolean selected(Query q,Job j,Map p){var time=dateTime(p,"jbp_bookingtime");return id(p,"csc_id")==id(j.job(),"csc_id_related")&&time!=null&&!time.isBefore(start(q))&&time.isBefore(end(q))&&(q.mode()==null||id(p,"jbp_mode")==q.mode())&&(q.view()==4&&q.counter()==2||id(p,"jbp_counter")== (q.counter()==1?1:0));} + static List> statistics(Query q,Map jobs,List> modes){var result=new ArrayList>();var groups=new TreeMap>>();var due=new HashMap>();var expected=new HashMap(); + 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);} + } + 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());} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CashflowActions.java b/backend/src/main/java/de/stadtbote/votian/CashflowActions.java new file mode 100644 index 0000000..dc352ec --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CashflowActions.java @@ -0,0 +1,47 @@ +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.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Reviewed, atomic equivalents of changeJbPayment/changeCounter/moveJbPayment. */ +@RestController +@RequestMapping("/api/cashflow/actions") +public final class CashflowActions { + final Cashflow cash;final DatabaseService db;final BusinessSupport support; + public CashflowActions(Cashflow cash){this.cash=cash;db=cash.db;support=cash.support;} + enum Kind { MODE, COUNTER, BOOK_INSTALLATION, REMOVE_INSTALLATION } + record Input(Cashflow.Query query,Kind kind,long job,long payment,Integer mode){} + record Command(String requestId,String revision,Input input,boolean confirmed){} + record State(Cashflow.Data data,Map job,List> payments,List> invoices,List> metadata,boolean locked){} + Input check(LegacyUser u,Input i){u.require(26);u.require(7);if(i==null||i.kind()==null||i.job()<=0||i.payment()<0||i.query()==null||i.query().view()!=0)throw bad("Bitte eine gültige Änderung aus der Kassenübersicht wählen.");if((i.kind()==Kind.MODE||i.kind()==Kind.REMOVE_INSTALLATION)&&i.payment()<=0)throw bad("Bitte eine Zahlung auswählen.");if((i.kind()==Kind.MODE||i.kind()==Kind.BOOK_INSTALLATION)&&(i.mode()==null||i.mode()<0||i.mode()>127))throw bad("Bitte eine gültige Zahlart auswählen.");return i;} + State state(LegacyUser original,Input i,boolean lock){var d=cash.read(original,i.query());if(!d.jobs().containsKey(i.job()))throw bad("Der Auftrag gehört nicht mehr zur gewählten Kassenübersicht.");var u=cash.scope(original,d.query().headquarters());String sql="SELECT * FROM job WHERE jb_id=? AND hq_id=?";var job=one(lock?db.lockRows(sql,i.job(),u.headquartersId()):db.queryForList(sql,i.job(),u.headquartersId()));String pay="SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_id";var payments=lock?db.lockRows(pay,i.job()):db.queryForList(pay,i.job());var invoices=db.queryForList("SELECT inv_no,inv_datetime FROM invoice WHERE jb_id=?",i.job());var metadata=db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_invoice_lock','jb_no_payment_job') ORDER BY gdc_id",i.job());var settings=OrderMetadata.settings(db,u.headquartersId());boolean locked=!db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND jb_locktime>DATE_SUB(NOW(),INTERVAL ? MINUTE)",i.job(),settings.lockMinutes()).isEmpty();return new State(d,job,payments,invoices,metadata,locked);} + static boolean open(Map payment){return id(payment,"jbpc_id")==0&&!OrderPayments.exportedPayment(payment);} + static BigDecimal remaining(State s){return Cashflow.net(s.job().get("jb_totalprice")).subtract(Cashflow.sum(s.payments(),"jbp_price"));} + static List> affected(State s,Input i){return s.payments().stream().filter(p->i.kind()==Kind.COUNTER?id(p,"jbp_counter")==0&&open(p)&&Cashflow.net(p.get("jbp_price")).signum()!=0:id(p,"jbp_id")==i.payment()).toList();} + void editable(State s,Input i){var j=s.job();if(s.locked())throw bad("Der Auftrag wird gerade in der alten Anwendung bearbeitet.");if(OrderOperations.exported(j)||!s.invoices().isEmpty()||s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))throw bad("Der Auftrag ist exportiert oder einer Rechnung zugeordnet.");if(id(j,"jb_storno")!=0||id(j,"jb_offer")!=0)throw bad("Stornos und Angebote können hier nicht umgebucht werden.");if(s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("jb_no_payment_job")&&text(m,"gdc_content").equals("1")))throw bad("Der Auftrag wird vom Transporteur bezahlt.");if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",i.job()).isEmpty())throw bad("Zum Auftrag besteht bereits eine Gegenbuchung."); + if(i.kind()==Kind.MODE||i.kind()==Kind.BOOK_INSTALLATION)if(s.data().modes().stream().noneMatch(m->id(m,"mt_sort")==i.mode()))throw bad("Die Zahlart ist im Kundenkatalog nicht freigegeben."); + if(i.kind()==Kind.BOOK_INSTALLATION||i.kind()==Kind.REMOVE_INSTALLATION)if((id(j,"jb_service")&2)!=2)throw bad("Diese Umbuchung ist nur für einen Montageauftrag möglich."); + var affected=affected(s,i);if(i.kind()!=Kind.BOOK_INSTALLATION){if(affected.isEmpty())throw bad("Es gibt keine passende, noch offene Zahlung.");if(affected.stream().anyMatch(p->!open(p)))throw bad("Gesammelte oder exportierte Zahlungen können nicht geändert werden.");if(i.kind()==Kind.REMOVE_INSTALLATION&&affected.stream().anyMatch(p->id(p,"jbp_counter")!=1))throw bad("Nur eine Montagebuchung der Kasse kann zurückgenommen werden.");} + else{if(!s.data().query().from().equals(s.data().query().to()))throw bad("Für eine Montagebuchung bitte einen einzelnen Kassentag wählen.");BigDecimal amount=remaining(s);if(amount.signum()==0)throw bad("Der Montageauftrag ist bereits vollständig bezahlt.");if(amount.abs().compareTo(new BigDecimal("999999.9999"))>0)throw bad("Der offene Betrag überschreitet den zulässigen Zahlungsbetrag.");} + } + @PostMapping("/preview") Map preview(Authentication a,@RequestBody Input i){return preview(ApiController.user(a),i);} + Map preview(LegacyUser u,Input raw){var i=check(u,raw);return db.readTransaction(()->{var s=state(u,i,false);editable(s,i);BigDecimal amount=i.kind()==Kind.BOOK_INSTALLATION?remaining(s):Cashflow.sum(affected(s,i),"jbp_price");String action=switch(i.kind()){case MODE->"Gebuchte Zahlart ändern";case COUNTER->"Monteur-Inkasso in Kasse umwandeln";case BOOK_INSTALLATION->"Offenen Montagebetrag in die Kasse einbuchen";case REMOVE_INSTALLATION->"Montagebuchung aus der Kasse zurücknehmen";};return fields("input",i,"revision",revision(List.of(s,i)),"action",action,"job",i.job(),"mode",i.mode()==null?"":Cashflow.label(s.data().modes(),i.mode()),"net",amount,"gross",Cashflow.gross(amount),"booked",i.kind()==Kind.BOOK_INSTALLATION?s.data().query().from().atTime(23,59,59).toString():"","payments",affected(s,i).stream().map(p->fields("id",id(p,"jbp_id"),"label",Cashflow.label(s.data().modes(),id(p,"jbp_mode")),"gross",Cashflow.gross(Cashflow.net(p.get("jbp_price"))))).toList());});} + @PostMapping Map save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + Map save(LegacyUser original,Command c){if(c==null||!c.confirmed()||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Bitte die Kassenänderung prüfen und bestätigen.");var i=check(original,c.input());BusinessSupport.token(c.requestId());var u=cash.scope(original,i.query().headquarters());support.transactional("headquarters","job","jobpayment","genericdatacontainer"); + db.writeTransaction(()->{lockHeadquarters(db,u);var intent=List.of("CASHFLOW",c);if(support.receipt(u,c.requestId(),intent)!=null)return null;var s=state(original,i,true);unchanged(List.of(s,i),c.revision());editable(s,i);var affected=affected(s,i);long payment=i.payment(); + switch(i.kind()){ + case MODE->db.update("UPDATE jobpayment SET jbp_mode=? WHERE jbp_id=? AND jb_id=?",i.mode(),i.payment(),i.job()); + case COUNTER->{for(var p:affected)db.update("UPDATE jobpayment SET jbp_counter=1 WHERE jbp_id=? AND jb_id=?",id(p,"jbp_id"),i.job());} + case BOOK_INSTALLATION->payment=db.insert("jobpayment",fields("jb_id",i.job(),"tr_sort",2,"csc_id",id(s.job(),"csc_id_related"),"jbp_mode",i.mode(),"jbp_price",remaining(s),"jbp_bookingtime",s.data().query().from().atTime(23,59,59),"jbp_counter",1,"jbp_tan","","jbpc_id",0)); + case REMOVE_INSTALLATION->db.update("DELETE FROM jobpayment WHERE jbp_id=? AND jb_id=?",i.payment(),i.job()); + } + db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND hq_id=?",i.job(),u.headquartersId());support.audit(u,i.job(),s.data().query().customer(),0,i.kind()==Kind.BOOK_INSTALLATION||i.kind()==Kind.REMOVE_INSTALLATION?121:118,"CASHFLOW="+i.kind()+" PAYMENT="+payment+" MODE="+i.mode()+" BEFORE="+support.encode(affected),c.requestId());support.receipt(u,c.requestId(),intent,payment);return null; + });return cash.query(original,i.query()); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java b/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java new file mode 100644 index 0000000..d645d75 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CashflowDocuments.java @@ -0,0 +1,42 @@ +package de.stadtbote.votian; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; +import org.apache.pdfbox.pdmodel.*; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.bad; + +@RestController +@RequestMapping("/api/cashflow") +public final class CashflowDocuments { + final Cashflow cash;public CashflowDocuments(Cashflow cash){this.cash=cash;} + record Request(Cashflow.Query query,String revision){} + 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 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 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();} + pdf.room(50);pdf.line("Summe Einnahmen: "+amount(Cashflow.sum(data.rows(),"paid"))+" EUR",12);if(!data.dayTotals().isEmpty()){pdf.space();pdf.line("Kasseneinnahmen nach Buchungszeitraum",11);for(var row:data.dayTotals())pdf.line(text(row,"label")+": "+amount(row.get("amount"))+" EUR",10);}return pdf.finish(); + }catch(IOException e){throw new IllegalStateException("Kassen-PDF konnte nicht erstellt werden.",e);}catch(IllegalArgumentException e){throw bad("Ein Zeichen in den Kassendaten wird von der PDF-Schrift nicht unterstützt.");}} + static final class Pdf implements AutoCloseable { + final PDDocument document=new PDDocument();final PDType0Font font;PDPageContentStream stream;float y; + Pdf()throws IOException{try(var in=PDDocument.class.getResourceAsStream("/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf")){font=PDType0Font.load(document,in,true);}document.getDocumentInformation().setTitle("Kassenübersicht");document.getDocumentInformation().setCreator("Votian NG");page();} + void page()throws IOException{if(stream!=null)stream.close();if(document.getNumberOfPages()>=200)throw bad("Die Kassenausgabe überschreitet 200 Seiten. Bitte Auswahl eingrenzen.");var page=new PDPage(PDRectangle.A4);document.addPage(page);stream=new PDPageContentStream(document,page);y=795;} + void room(float height)throws IOException{if(y-height<55)page();}void space(){y-=12;} + void line(String raw,float size)throws IOException{for(String paragraph:raw.replace('\u00a0',' ').replaceAll("[\\p{Cntrl}&&[^\\n]]"," ").split("\n")){String value=paragraph.strip();while(!value.isEmpty()){float width=0;int end=0,space=-1;for(int i=0;i505&&end>0)break;width+=w;end=next;if(Character.isWhitespace(value.charAt(i)))space=i;i=next;}if(end0)end=space;room(size+5);draw(stream,value.substring(0,end).stripTrailing(),42,y,size);y-=size+5;value=value.substring(end).stripLeading();}}} + void draw(PDPageContentStream target,String text,float x,float y,float size)throws IOException{target.beginText();target.setFont(font,size);target.newLineAtOffset(x,y);target.showText(text);target.endText();} + byte[] finish()throws IOException{stream.close();stream=null;for(int i=0;i to){} + record Snapshot(Cashflow.Data data,Envelope envelope,String subject,String body,String revision){} + record Delivery(long headquarters,Cashflow.Query query,String subject,Envelope envelope,String startedAt,String state){} + static class Pending {long user,originalHeadquarters;Draft draft;Snapshot snapshot;byte[] pdf;Instant expires;} + final Cashflow cash;final AppointmentMailTransport transport;final Map pending=new LinkedHashMap<>(); + public CashflowMail(Cashflow cash,AppointmentMailTransport transport){this.cash=cash;this.transport=transport;} + static String context(Cashflow.Query q){return revision(q);} + String fallback(long hq,String key){String v=cash.catalog.parameter(hq,key);return v.isBlank()?cash.catalog.parameter(0,key):v;} + String customerParameter(Cashflow.Query q,String key){return new OrderCustomerMailContent(cash.db,q.headquarters(),"").object(key,q.customer());} + Envelope envelope(Cashflow.Query q){var to=NewsletterDispatch.addresses(customerParameter(q,"MASK_STAT_CASHFLOW_MAILTO_DEFAULT"));if(to.isEmpty()||to.size()>100)throw bad("Bitte 1 bis 100 Empfänger in MASK_STAT_CASHFLOW_MAILTO_DEFAULT hinterlegen.");return new Envelope(AppointmentNotifications.address(fallback(q.headquarters(),"MAIL_SENDER_ADDRESS")),to);} + static String subject(Cashflow.Data d){var f=DateTimeFormatter.ofPattern("dd.MM.yyyy");var q=d.query();return (q.customer()==0?"NUR INFO :: ":"")+"KASSE :: "+q.from().format(f)+(q.from().equals(q.to())?"":" - "+q.to().format(f))+" :: "+d.customer();} + 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);} + 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);} + Map options(LegacyUser u,Cashflow.Query raw){return cash.db.readTransaction(()->{var d=cash.read(u,raw);var q=d.query();var e=envelope(q);String body=fallback(q.headquarters(),q.customer()==0?"MASK_STAT_CASHFLOW_MAILINFOTEXT_DEFAULT":"MASK_STAT_CASHFLOW_MAILTEXT_DEFAULT");if(body.isBlank())body="Bitte beachten Sie das Dokument in der Anlage!";return fields("from",e.from(),"to",e.to(),"subject",subject(d),"body",body,"configured",transport.configured(),"history",history(u,q));});} + Snapshot snapshot(LegacyUser u,Draft draft){if(draft==null)throw bad("Die Versandvorschau fehlt.");textCheck(draft.subject(),draft.body());var d=cash.read(u,draft.query());unchanged(d,draft.revision());if(d.rows().isEmpty())throw bad("Keine Kassenpositionen zum Versenden.");if(!transport.configured())throw bad("Der SMTP-Versand ist nicht konfiguriert.");exported(u,d.query());var e=envelope(d.query());return new Snapshot(d,e,draft.subject().strip(),draft.body(),revision(List.of(d,e,draft.subject().strip(),draft.body())));} + @PostMapping("/preview") Map preview(Authentication a,@RequestBody Draft d){return preview(ApiController.user(a),d);} + Map preview(LegacyUser u,Draft d){u.require(26);var s=cash.db.readTransaction(()->snapshot(u,d));var p=new Pending();p.user=u.userId();p.originalHeadquarters=u.originalHeadquartersId();p.draft=d;p.snapshot=s;p.pdf=CashflowDocuments.pdf(s.data());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(x->x.expires.isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele offene Mailvorschauen. Bitte später erneut versuchen.");pending.put(token,p);}return fields("token",token,"from",s.envelope().from(),"to",s.envelope().to(),"subject",s.subject(),"body",s.body(),"filename","Kasse.pdf","pdf",Base64.getEncoder().encodeToString(p.pdf),"page",VehicleRollcardPdf.page(p.pdf,0));} + Pending pending(LegacyUser u,String token){u.require(26);BusinessSupport.token(token);Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.user!=u.userId()||p.originalHeadquarters!=u.originalHeadquartersId()||p.expires.isBefore(Instant.now()))throw bad("Die Versandvorschau ist abgelaufen oder nach einem Neustart nicht verfügbar. Bitte den Versandverlauf prüfen.");cash.check(u,p.draft.query());return p;} + @GetMapping("/{token}/page") Map page(Authentication a,@PathVariable String token,@RequestParam int page){var p=pending(ApiController.user(a),token);return VehicleRollcardPdf.page(p.pdf,page);} + Delivery decode(Map r){try{return cash.support.json.readValue(text(r,"gdc_content"),Delivery.class);}catch(Exception e){throw bad("Der Versandstatus ist nicht lesbar. Bitte vor erneutem Versand prüfen lassen.");}} + Delivery stored(LegacyUser u,String token){u.require(26);BusinessSupport.token(token);var rows=cash.db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),token);if(rows.isEmpty())return null;var d=decode(one(rows));cash.check(u,d.query());return d;} + static Map result(String token,Delivery d){return fields("token",token,"state",d.state(),"startedAt",d.startedAt(),"subject",d.subject(),"to",d.envelope().to(),"message",d.state().equals("SENT")?"Der SMTP-Server hat die E-Mail angenommen. Dies bestätigt noch nicht die Zustellung.":"Die Übergabe wurde gestartet, aber nicht sicher bestätigt. Es erfolgt kein automatischer Neuversand. Bitte Zustellung oder SMTP-Protokoll prüfen.");} + List> history(LegacyUser u,Cashflow.Query q){return cash.db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcashmail' AND gdc_obj_id=? AND gdc_context=? ORDER BY gdc_id DESC LIMIT 20",u.userId(),context(q)).stream().map(r->result(text(r,"gdc_gen_fieldname"),decode(r))).toList();} + @PostMapping("/history") List> history(Authentication a,@RequestBody Cashflow.Query q){var u=ApiController.user(a);return history(u,cash.check(u,q));} + @GetMapping("/{token}") Map status(Authentication a,@PathVariable String token){return status(ApiController.user(a),token);} + Map status(LegacyUser u,String token){var d=stored(u,token);if(d==null)throw bad("Für diese Kennung wurde noch keine Übergabe gestartet.");return result(token,d);} + @PostMapping("/{token}") Map send(Authentication a,@PathVariable String token,@RequestBody Confirm c){return send(ApiController.user(a),token,c);} + Map send(LegacyUser u,String token,Confirm c){u.require(26);if(c==null||!c.recipientsChecked()||!c.contentChecked()||!c.exportChecked())throw bad("Bitte Empfänger, PDF-Inhalt und abgeschlossenen Einnahmenexport bestätigen.");var done=stored(u,token);if(done!=null)return result(token,done);var p=pending(u,token);cash.support.transactional("headquarters","genericdatacontainer");boolean claimed=cash.db.writeTransaction(()->{var actor=cash.scope(u,p.snapshot.data().query().headquarters());lockHeadquarters(cash.db,actor);if(stored(u,token)!=null)return false;var now=snapshot(u,p.draft);if(!now.revision().equals(p.snapshot.revision()))throw VehicleDisposition.conflict();var delivery=new Delivery(actor.headquartersId(),now.data().query(),now.subject(),now.envelope(),LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString(),"STARTED");cash.db.insert("genericdatacontainer",fields("gdc_obj_type","ngcashmail","gdc_obj_id",u.userId(),"gdc_gen_fieldname",token,"gdc_context",context(delivery.query()),"gdc_content",cash.support.encode(delivery)));cash.support.audit(actor,0,delivery.query().customer(),0,203,"Kassenmail zur Übergabe vorgemerkt · "+delivery.query().from()+" bis "+delivery.query().to(),token);return true;});if(!claimed)return result(token,stored(u,token)); + String state="SENT";try{var s=p.snapshot;transport.sendPdf(s.envelope().from(),s.envelope().to(),List.of(),List.of(),s.subject(),s.body(),"Kasse.pdf",p.pdf);}catch(RuntimeException failure){state="UNCERTAIN";}var started=stored(u,token);var finished=new Delivery(started.headquarters(),started.query(),started.subject(),started.envelope(),started.startedAt(),state);cash.db.writeTransaction(()->cash.db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='ngcashmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",cash.support.encode(finished),u.userId(),token));return result(token,finished); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CentralEntryTracking.java b/backend/src/main/java/de/stadtbote/votian/CentralEntryTracking.java new file mode 100644 index 0000000..ff6b0a8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CentralEntryTracking.java @@ -0,0 +1,24 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.AdminSupport.bad; +/** The central PHP mask exposes per-stop recipients according to the chosen payer. */ +final class CentralEntryTracking { + final DatabaseService db; + CentralEntryTracking(DatabaseService db){this.db=db;} + static List emails(Request r){return r.stops().stream().map(s->Objects.toString(s.trackingEmail(),"")).toList();} + static boolean hasEmails(Request r){return emails(r).stream().anyMatch(v->!v.isBlank());} + void lock(Request r){if(!hasEmails(r))return; + Integer engines=db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('customer','costcenter') AND ENGINE='InnoDB'",Integer.class); + if(!Objects.equals(engines,2))throw bad("Tracking-E-Mails benötigen transaktionsfähige Kunden und Kostenstellen."); + db.lockRows("SELECT cs.cs_id FROM customer cs JOIN costcenter cc ON cc.cs_id=cs.cs_id WHERE cc.csc_id=?",r.payerCostCenterId());} + void validate(LegacyUser u,Request r,long customer){ + if(!hasEmails(r))return; + if(u.customerAccount())throw bad("Tracking-E-Mails im Kundenzugang bitte über die freigegebenen Kundeneingaben übermitteln."); + if(!new CustomerEntryTracking(db).enabled(customer))throw bad("Tracking-E-Mails sind für den gewählten Zahler nicht freigegeben."); + CustomerEntryTracking.validate(emails(r),r.stops().size(),true); + } + static Quote apply(Quote q,Request r){return hasEmails(r)?q.withMediationDeadline(q.mediationDeadline(),LegacyOrderPricing.hash(q.fingerprint()+"|tracking|"+OrderEntryExtras.json(emails(r)))):q;} + void created(long job,Request r,boolean showHint){if(!hasEmails(r))return;var tracking=new CustomerEntryTracking(db);tracking.created(job,CustomerEntryTracking.validate(emails(r),r.stops().size(),true));tracking.hint(job,showHint);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ClassicCandidates.java b/backend/src/main/java/de/stadtbote/votian/ClassicCandidates.java new file mode 100644 index 0000000..90b8bac --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ClassicCandidates.java @@ -0,0 +1,108 @@ +package de.stadtbote.votian; + +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; + +/** PHP CLASSIC groups, kept separate from the autonomous mediation worker. Discovery is read-only. */ +@RestController +@RequestMapping("/api/orders/{job}/courier-assignment/classic-candidates") +public class ClassicCandidates { + final DatabaseService db;final OrderOperations orders;final OrderEntryCatalog catalog; + public ClassicCandidates(DatabaseService db,OrderOperations orders,OrderEntryCatalog catalog){this.db=db;this.orders=orders;this.catalog=catalog;} + record Zone(int rank,String group,String name){} + record Geography(String mode,String postalCode,String main,Map zones,List warnings,List evidence){} + record Candidate(Map vehicle,Zone zone,boolean favourite,boolean eligible,List blockers,List warnings){} + static final Zone REST=new Zone(10000,"Anderweitige verfügbare Kuriere",""); + static final Zone FAVOURITE=new Zone(0,"Bevorzugte Kuriere",""); + @GetMapping public Object get(Authentication auth,@PathVariable long job){return list(ApiController.user(auth),job);} + Map list(LegacyUser u,long job){OrderCourierAssignment.access(u);return db.readTransaction(()->{ + var s=orders.snapshot(u,job,false);if(id(s.job(),"jb_longhaul")!=0)throw bad("Bitte für Fernaufträge die BWV-Auswahl verwenden."); + var geo=geography(db,catalog,u,s);var rules=rules(db,catalog,u,s);String restriction=LonghaulCandidates.setting(catalog,u,"RANKING_USE_CRVH_RESTRICTION"); + var rows=vehicles(db,catalog,u,0);var assignments=assignments(db,s);var previous=new HashSet();db.queryForList("SELECT cr_id FROM autoranking WHERE jb_id=?",job).forEach(r->previous.add(id(r,"cr_id"))); + var candidates=new ArrayList();for(var v:rows){var c=candidate(u,s,v,geo,rules,"1".equals(restriction));if(visible(u,c,rules))candidates.add(c);} + boolean occupiedFirst="1".equals(rules.values().get("MASK_COURIER_SORT_BY_OCCUPIED")); + candidates.sort(ranking(occupiedFirst,assignments)); + return fields("mode",geo.mode(),"startPostalCode",geo.postalCode(),"area",geo.main(),"warnings",geo.warnings(),"occupiedFirst",occupiedFirst,"orderRevision",revision(s),"items",candidates.stream().map(c->view(c,assignments.getOrDefault(text(c.vehicle(),"crvh_sid"),Map.of()),previous)).toList()); + });} + static List> vehicles(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,long vehicle){ + String sql="SELECT cv.*,"+AssignmentVehicles.IDENTITY+",cr.hq_id AS courier_hq,cr.cr_sid,cr.cr_available,cr.cr_locationzipcode,cr.cr_availabletime,cr.cr_occupied,cr.cr_outlay,cr.cr_filter,cr.vht_id AS ranking_vehicle_type,u.usr_country,cmp.cmp_comp,h.hq_mnemonic,mt.mt_value AS vehicle_type "+AssignmentVehicles.joins(AssignmentVehicles.from(true,AssignmentVehicles.multiple(catalog,u)),"JOIN headquarters h ON h.hq_id=cr.hq_id JOIN metatype mt ON mt.mt_type='vehicletype' AND mt.mt_sort=cv.vht_id")+" AND cr.cr_sid=cv.crvh_sid"+(vehicle>0?" AND cv.crvh_id=?":"")+" ORDER BY cv.crvh_sid,cv.crvh_id"; + return AssignmentVehicles.identities(db.queryForList(sql,vehicle>0?new Object[]{vehicle}:new Object[]{})); + } + static void requireForeignSelection(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s,long vehicle,long courier,List evidence){ + if(id(s.job(),"jb_longhaul")!=0)throw bad("Für diesen Auftrag ist die gewählte Niederlassung nicht zulässig."); + var rows=vehicles(db,catalog,u,vehicle);if(rows.size()!=1||id(rows.getFirst(),"cr_id")!=courier)throw bad("Das Fahrzeug der anderen Niederlassung ist nicht mehr angemeldet."); + var geo=geography(db,catalog,u,s);var rules=rules(db,catalog,u,s);var c=candidate(u,s,rows.getFirst(),geo,rules,true); + if(c.zone().rank()==REST.rank()||!c.eligible())throw bad("Das Fahrzeug der anderen Niederlassung gehört nicht mehr zu den freigegebenen Favoriten oder Vermittlungsbereichen."); + evidence.add(List.of(geo,rules,c)); + } + static LonghaulCandidates.Rules rules(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s){ + var base=LonghaulCandidates.rules(db,catalog,u,s);var values=new TreeMap<>(base.values());for(String key:List.of("GLOBAL_NATIONALITY_DEFAULT","GLOBAL_NATIONALITY_WHITELIST","EU_COUNTRYCODES"))values.put(key,catalog.parameter(0,key));return new LonghaulCandidates.Rules(values,base.favourites(),base.blocked(),base.filters()); + } + static boolean visible(LegacyUser u,Candidate c,LonghaulCandidates.Rules rules){if(id(c.vehicle(),"cr_available")!=1&&!rules.blocked().contains(id(c.vehicle(),"cr_id")))return false;return id(c.vehicle(),"courier_hq")==u.headquartersId()||c.zone().rank()!=REST.rank()||rules.blocked().contains(id(c.vehicle(),"cr_id"));} + static Candidate candidate(LegacyUser u,OrderOperations.Snapshot s,Map v,Geography geo,LonghaulCandidates.Rules rules,boolean restrictRest){ + var preference=LonghaulCandidates.preference(rules,id(v,"cr_id"));var area=geo.zones().getOrDefault(text(v,"cr_locationzipcode"),REST);var zone=preference.favourite()?FAVOURITE:area; + var blocks=new ArrayList();var warnings=new ArrayList(); + boolean limits=zone.rank()!=REST.rank()||restrictRest; + if(AssignmentVehicles.ambiguous(v,"1".equals(rules.values().get(AssignmentVehicles.SETTING))))blocks.add("Mehrere Fahrer sind auf diesem Fahrzeug angemeldet"); + if(id(v,"cr_available")!=1)blocks.add("Nicht verfügbar gemeldet"); + if(rules.blocked().contains(id(v,"cr_id")))blocks.add("Für einen beteiligten Kunden gesperrt"); + if(id(v,"ranking_vehicle_type")id(v,pair[1]))(limits&&preference.measuresRequired()&&"1".equals(rules.values().get("RANKING_JB2CRVH_MEASURE"))?blocks:warnings).add(pair[2]+" nicht ausreichend"); + for(String needed:FleetAssignment.tokens(text(s.job(),"jb_cr_filter"))){var types=rules.filters().stream().filter(f->text(f,"crf_short").equals(needed)).map(f->id(f,"crf_type")).distinct().toList();boolean missing=types.isEmpty()?!FleetAssignment.tokens(text(v,"cr_filter")+","+text(v,"crvh_filter")).contains(needed):types.stream().anyMatch(t->!FleetAssignment.tokens(text(v,t==0?"crvh_filter":"cr_filter")).contains(needed));if(missing)(types.isEmpty()||!preference.filterRequired()?warnings:blocks).add("Filterbedingung nicht erfüllt: "+needed);} + var countries=new TreeSet();s.stops().forEach(t->countries.add(country(text(t,"ad_country"))));String defaultCountry=rules.values().getOrDefault("GLOBAL_NATIONALITY_DEFAULT","");if(defaultCountry.isBlank())defaultCountry="DE";String nationality=country(text(v,"usr_country")); + if(!nationality.isBlank()&&!nationality.equals(defaultCountry))for(String entry:rules.values().getOrDefault("GLOBAL_NATIONALITY_WHITELIST","").split("\\|")){var parts=entry.split("=",2);if(parts.length==2&&countries.contains(parts[0].strip())&&!parts[0].strip().equals(defaultCountry)&&!parts[0].strip().equals(nationality)&&!FleetAssignment.tokens(parts[1]).contains(nationality))blocks.add("Die hinterlegte Länderfreigabe ist für diese Tour nicht erfüllt.");} + String eu=rules.values().getOrDefault("EU_COUNTRYCODES","");if(id(v,"crvh_totalweight")>=2800&&!FleetAssignment.tokens(text(v,"crvh_filter")).contains("EG")&&countries.stream().anyMatch(c->!c.equals("DE")&&!c.isEmpty()&&eu.contains(c)))warnings.add("Für diese Auslandstour fehlt die EG-Lizenz des Fahrzeugs."); + return new Candidate(v,zone,preference.favourite(),blocks.isEmpty(),List.copyOf(blocks),List.copyOf(warnings)); + } + static String country(String value){return value.equals("D")?"DE":value;} + static Geography geography(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s){ + if(s.stops().isEmpty())throw bad("Dem Auftrag fehlt die Abholstation.");String postal=text(s.stops().getFirst(),"ad_zipcode");if(postal.isBlank())throw bad("Der Abholstation fehlt die PLZ."); + var evidence=new ArrayList();var warnings=new ArrayList();var zones=new TreeMap(); + String mode=LonghaulCandidates.setting(catalog,u,"MODE_INTERMEDIATION");if(mode.isBlank())mode=text(one(db.queryForList("SELECT hq_invmode FROM headquarters WHERE hq_id=?",u.headquartersId())),"hq_invmode");evidence.add(mode); + if(!Set.of("1","2","3").contains(mode)){warnings.add("Für diese Niederlassung ist kein CLASSIC-Vermittlungsbereich konfiguriert.");return new Geography(mode,postal,"",zones,warnings,evidence);} + var zip=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? ORDER BY srvp_id",postal);evidence.add(zip);if(zip.size()>1)throw bad("Die Abhol-PLZ ist im Bestand mehrdeutig."); + if(mode.equals("1"))zones.put(postal,new Zone(10,"In der Abhol-PLZ",postal)); + if(zip.isEmpty()){warnings.add("Für die Abhol-PLZ fehlen Bereichs-/Nachbarschaftszuordnungen.");return new Geography(mode,postal,mode.equals("1")?postal:"",zones,warnings,evidence);} + long source=id(zip.getFirst(),"srvp_id");String main=postal; + if(mode.equals("1")){ + var neighbours=db.queryForList("SELECT p.srvp_plz FROM serviceplzneighbour n JOIN serviceplz p ON p.srvp_id=n.srvp2_id WHERE n.hq_id=? AND n.srvp_id=? ORDER BY p.srvp_plz",u.headquartersId(),source);evidence.add(neighbours); + neighbours.forEach(n->zones.putIfAbsent(text(n,"srvp_plz"),new Zone(21,"In der PLZ-Nachbarschaft",text(n,"srvp_plz")))); + }else{ + var areas=db.queryForList("SELECT a.srvpa_id,a.srvpa_name,a.srvpa_longname FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE m.srvp_id=? AND a.hq_id=? ORDER BY a.srvpa_id",source,u.headquartersId());evidence.add(areas); + if(areas.size()>1)throw bad("Die Abhol-PLZ gehört zu mehreren Vermittlungsbereichen."); + if(areas.isEmpty()){warnings.add("Die Abhol-PLZ ist keinem Vermittlungsbereich zugeordnet.");main="";} + else{ + var a=areas.getFirst();main=text(a,"srvpa_name");addArea(db,u,zones,a,new Zone(10,"Im Vermittlungsbereich "+main,main),evidence); + int levels=levels(LonghaulCandidates.setting(catalog,u,"AUTORANKING_NEIGHBOUR_LEVEL"));evidence.add(levels); + var neighbours=mode.equals("2")?db.queryForList("SELECT a.srvpa_id,a.srvpa_name,a.srvpa_longname,n.srvpan_sort AS neighbour_level FROM serviceplzareaneighbour n JOIN serviceplzarea a ON a.srvpa_id=n.srvpa2_id WHERE n.srvpa_id=? AND a.hq_id=? AND n.srvpan_sort BETWEEN 1 AND ? ORDER BY n.srvpan_sort,a.srvpa_id",id(a,"srvpa_id"),u.headquartersId(),levels):db.queryForList("SELECT a.srvpa_id,a.srvpa_name,a.srvpa_longname,n.srvpna_sort AS neighbour_level FROM serviceplzneighbourarea n JOIN serviceplzarea a ON a.srvpa_id=n.srvpa_id WHERE n.srvp_id=? AND n.hq_id=? AND a.hq_id=? AND n.srvpna_sort BETWEEN 1 AND ? ORDER BY n.srvpna_sort,a.srvpa_id",source,u.headquartersId(),u.headquartersId(),levels);evidence.add(neighbours); + for(var n:neighbours)addArea(db,u,zones,n,new Zone(20+(int)id(n,"neighbour_level"),"Nachbarschaft "+id(n,"neighbour_level")+" · "+text(n,"srvpa_name"),text(n,"srvpa_name")),evidence); + } + } + return new Geography(mode,postal,main,Collections.unmodifiableMap(zones),List.copyOf(warnings),List.copyOf(evidence)); + } + static int levels(String value){if(value.isBlank())return 0;try{int n=Integer.parseInt(value);if(n>=0&&n<=1000)return n;}catch(NumberFormatException ignored){}throw bad("AUTORANKING_NEIGHBOUR_LEVEL muss zwischen 0 und 1000 liegen.");} + static void addArea(DatabaseService db,LegacyUser u,Map zones,Map area,Zone zone,List evidence){var codes=db.queryForList("SELECT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=? ORDER BY p.srvp_plz",id(area,"srvpa_id"));evidence.add(codes);codes.forEach(c->zones.putIfAbsent(text(c,"srvp_plz"),zone));} + static final String OCCUPIED=""" +DATE(j.jb_ordertime)=CURRENT_DATE AND + (DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(lt.srvpt_traveltime,30) MINUTE)<=NOW() + OR (DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(lt.srvpt_traveltime,30) MINUTE)<=? AND j.jb_ordertime>=?)) + """; + static Map> assignments(DatabaseService db,OrderOperations.Snapshot s){ + var rows=db.queryForList(""" + SELECT j.cr_sid,COUNT(DISTINCT j.jb_id) AS active_count,MIN(j.jb_ordertime) AS earliest_order, + MAX(CASE WHEN %s THEN 1 ELSE 0 END) AS occupied + FROM job j JOIN tour t ON t.jb_id=j.jb_id AND t.tr_sort=1 JOIN address a ON a.ad_id=t.ad_id + LEFT JOIN serviceplz p ON p.srvp_plz=a.ad_zipcode LEFT JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id AND lt.hq_id=j.hq_id + WHERE j.jb_id<>? AND j.jb_status IN (0,1) AND COALESCE(j.jb_storno,0)=0 AND j.cr_sid<>'' GROUP BY j.cr_sid + """.formatted(OCCUPIED),dateTime(s.job(),"jb_ordertime"),dateTime(s.job(),"jb_ordertime"),id(s.job(),"jb_id")); + var result=new HashMap>();rows.forEach(r->result.put(text(r,"cr_sid"),r));return result; + } + static Comparator ranking(boolean occupiedFirst,Map> assignments){return Comparator.comparingInt(c->c.eligible()?0:1).thenComparingInt(c->occupiedFirst&&id(assignments.getOrDefault(text(c.vehicle(),"crvh_sid"),Map.of()),"occupied")!=0?1:0).thenComparingInt(c->c.zone().rank()).thenComparing(c->c.zone()==FAVOURITE||c.zone()==REST?text(c.vehicle(),"cr_locationzipcode"):c.zone().name()).thenComparing(c->text(c.vehicle(),"cr_availabletime")).thenComparing(c->text(c.vehicle(),"crvh_sid"));} + static Map view(Candidate c,Map assignment,Set previous){var v=c.vehicle();return fields("id",id(v,"crvh_id"),"courierId",id(v,"cr_id"),"ownerCourierId",id(v,"owner_courier_id"),"driverName",text(v,"driver_name"),"sid",text(v,"crvh_sid"),"name",text(v,"cmp_comp"),"plate",text(v,"crvh_vh_sign"),"vehicleType",id(v,"vht_id"),"vehicleTypeName",text(v,"vehicle_type"),"headquarters",text(v,"hq_mnemonic"),"online",true,"available",id(v,"cr_available")==1,"occupied",id(assignment,"occupied")!=0,"reportedOccupied",id(v,"cr_occupied")!=0,"postalCode",text(v,"cr_locationzipcode"),"availableTime",text(v,"cr_availabletime"),"group",c.zone().group(),"groupRank",c.zone().rank(),"favourite",c.favourite(),"previouslyOffered",previous.contains(id(v,"cr_id")),"activeOrders",id(assignment,"active_count"),"earliestOrder",text(assignment,"earliest_order"),"blockers",c.blockers(),"warnings",c.warnings());} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ContactCustomerPresets.java b/backend/src/main/java/de/stadtbote/votian/ContactCustomerPresets.java new file mode 100644 index 0000000..d02cfd1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ContactCustomerPresets.java @@ -0,0 +1,31 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** MASK_CS2CO_PRESET addresses metafield keys, not headquarters-specific category-key IDs. */ +final class ContactCustomerPresets { + static final String PARAMETER="MASK_CS2CO_PRESET"; + record Result(Map values,List warnings){} + static Map mappings(String raw){ + var result=new LinkedHashMap();if(raw==null||raw.isBlank())return result; + for(String part:raw.split("\\|",-1)){ + String[] pair=part.split("=",2);if(pair.length!=2||!pair[0].strip().matches("[1-9][0-9]{0,9}"))throw bad("Die Kontaktvorbelegung ist ungültig ("+PARAMETER+")."); + long key=Long.parseLong(pair[0].strip());if(result.put(key,pair[1].strip())!=null)throw bad("Die Kontaktvorbelegung enthält doppelte Feldschlüssel."); + }return result; + } + static Result resolve(DatabaseService db,LegacyUser u,Map customer,List> definitions){ + var mapping=mappings(new OrderEntryCatalog(db).parameter(0,PARAMETER));var mapped=new ArrayList>();var links=new LinkedHashMap();var warnings=new ArrayList(); + for(var d:definitions){String type=mapping.get(id(d,"keyId"));if(type==null)continue;long field=id(d,"id"); + if(text(d,"type").equals("co2cs")&&type.equals("db:cs:eid")){links.put(field,Long.toString(id(customer,"cs_id")));continue;} + if(MasterForms.dbKey("customers",type)==null){warnings.add(text(d,"label")+": Vorbelegung "+type+" wird nicht unterstützt.");continue;} + mapped.add(fields("id",field,"type",type)); + } + var values=new LinkedHashMap<>(new FormAutomaticValues(db,new FormFieldSupport(db)).values(u,"customers",customer,mapped,List.of()));values.putAll(links); + return new Result(values,List.copyOf(warnings)); + } + private ContactCustomerPresets(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ContactPresentation.java b/backend/src/main/java/de/stadtbote/votian/ContactPresentation.java new file mode 100644 index 0000000..f12bd3a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ContactPresentation.java @@ -0,0 +1,20 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Explicit PHP contact-column settings; native defaults remain available without configuration. */ +final class ContactPresentation { + record Settings(List header,List body,List sort){} + static Settings settings(DatabaseService db){var c=new OrderEntryCatalog(db);return new Settings(ContactVcard.positions(c.parameter(0,"MASK_CONTACT_HEADER_FIELDS_DISPLAYED"),2),ContactVcard.positions(c.parameter(0,"MASK_CONTACT_BODY_FIELDS_DISPLAYED"),200),ContactVcard.positions(c.parameter(0,"MASK_CONTACT_SORT_FIELDS"),200));} + static List> ordered(List> definitions,List> values){ + var byId=new HashMap>();for(var value:values)if(byId.put(id(value,"fieldId"),value)!=null)throw bad("Doppelte Kontaktfelder im Bestand. Bitte zuerst bereinigen."); + return definitions.stream().map(d->{var row=byId.get(id(d,"id"));return row!=null?row:fields("fieldId",id(d,"id"),"label",text(d,"label"),"type",text(d,"type"),"value","");}).toList(); + } + static String value(List> fields,int index){return index> values){return s.header().isEmpty()?"":String.join(", ",s.header().stream().map(i->value(values,i)).filter(v->!v.isBlank()).toList());} + static List body(Settings s,List> values){return s.body().isEmpty()?null:java.util.stream.IntStream.range(0,values.size()).filter(s.body()::contains).mapToObj(i->id(values.get(i),"fieldId")).toList();} + static String sort(Settings s,List> values,long contact){return s.sort().isEmpty()?ContactService.contactName(values).toLowerCase(Locale.ROOT):String.join(":",s.sort().stream().map(i->value(values,i)).toList())+":"+contact;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ContactService.java b/backend/src/main/java/de/stadtbote/votian/ContactService.java index 3452010..fc691c9 100644 --- a/backend/src/main/java/de/stadtbote/votian/ContactService.java +++ b/backend/src/main/java/de/stadtbote/votian/ContactService.java @@ -17,22 +17,31 @@ class ContactService { record Save(long headquarters,String revision,String schemaRevision,Map values){} private List> definitions(long hq){return db.queryForList("SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_history AS history,k.mtfk_name AS label,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id",hq);} Map schema(LegacyUser u,long hq){scope.checked(u,List.of(hq));var d=definitions(hq);if(d.isEmpty())throw bad("Für diese Niederlassung sind keine Kontaktfelder eingerichtet.");return fields("fields",d,"revision",revision(d));} + Map draft(LegacyUser u,long hq,Long customer){return db.readTransaction(()->{ + scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Für diese Niederlassung sind keine Kontaktfelder eingerichtet."); + var preset=new ContactCustomerPresets.Result(Map.of(),List.of()); + if(customer!=null){scope.customer(u,customer);var owner=one(db.queryForList("SELECT * FROM customer WHERE cs_id=?",customer));preset=ContactCustomerPresets.resolve(db,u,owner,defs);} + var defaults=preset; + return fields("id",0,"headquartersId",hq,"values",preset.values(),"presetWarnings",preset.warnings(),"customerLabels",customerLabels(u,defs.stream().filter(d->text(d,"type").equals("co2cs")).map(d->presetValue(d,defaults.values())).toList()),"revision","","schema",fields("fields",defs,"revision",revision(defs))); + });} + private static String presetValue(Map field,Map values){return values.get(id(field,"id"));} private List> raw(long id,List> defs){var args=new ArrayList(List.of(id));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());} - private Map customerLabels(LegacyUser u,Collection ids){var valid=ids.stream().filter(v->v!=null&&v.matches("[1-9][0-9]{0,9}")).distinct().toList();if(valid.isEmpty())return Map.of();var hqs=scope.headquarters(u).stream().map(h->id(h,"id")).toList();if(hqs.isEmpty())return Map.of();var args=new ArrayList(valid);args.addAll(hqs);var result=new HashMap();for(var r:db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs LEFT JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.cs_id IN ("+marks(valid.size())+") AND cs.hq_id IN ("+marks(hqs.size())+")",args.toArray()))result.put(Long.toString(id(r,"id")),text(r,"label"));return result;} + private Map customerLabels(LegacyUser u,Collection ids){var valid=ids.stream().filter(v->v!=null&&v.matches("[1-9][0-9]{0,9}")).distinct().toList();if(valid.isEmpty())return Map.of();var hqs=scope.headquarters(u).stream().map(h->id(h,"id")).toList();if(hqs.isEmpty())return Map.of();var args=new ArrayList(valid);args.addAll(hqs);var result=new HashMap();for(var r:db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id AND co.cmp_visible=1 AND co.cmp_authenticated=1 WHERE cs.cs_id IN ("+marks(valid.size())+") AND cs.hq_id IN ("+marks(hqs.size())+")",args.toArray()))result.put(Long.toString(id(r,"id")),text(r,"label"));return result;} Map get(LegacyUser u,long hq,long contact){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder eingerichtet.");var rows=raw(contact,defs);if(rows.isEmpty())one(List.of());var vals=new LinkedHashMap();for(var r:rows)if(vals.put(id(r,"mtfck_id"),text(r,"mtfv_value"))!=null)throw bad("Doppelte Kontaktfelder im Bestand. Bitte zuerst bereinigen.");return fields("id",contact,"headquartersId",hq,"values",vals,"customerLabels",customerLabels(u,defs.stream().filter(d->text(d,"type").equals("co2cs")).map(d->vals.get(id(d,"id"))).toList()),"revision",revision(rows),"schema",fields("fields",defs,"revision",revision(defs)));} List> list(LegacyUser u,Query q){if(q==null)throw bad("Kontaktfilter fehlen.");var h=scope.checked(u,q.headquarters());String term=q.search()==null?"":q.search().strip();if(term.length()>100||q.letter()!=null&&!q.letter().matches("[A-ZÄÖÜ]?"))throw bad("Ungültige Kontaktsuche.");if(q.customerId()!=null)scope.customer(u,q.customerId()); var args=new ArrayList(h);String where="ck.mtfc_id=1 AND ck.hq_id IN ("+marks(h.size())+")"; - if(!term.isEmpty()){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue sv JOIN metafieldcategorykey sk ON sk.mtfck_id=sv.mtfck_id JOIN metafieldkey fk ON fk.mtfk_id=sk.mtfk_id LEFT JOIN customer c ON fk.mtfk_type='co2cs' AND c.cs_id=sv.mtfv_value AND c.hq_id=sk.hq_id LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE sv.mtfv_id=v.mtfv_id AND sk.mtfc_id=1 AND sk.hq_id=ck.hq_id AND (sv.mtfv_value LIKE ? OR CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ?))";String like=(q.prefix()?"":"%")+term.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";args.add(like);args.add(like);} + if(!term.isEmpty()){var allowed=scope.headquarters(u).stream().map(r->id(r,"id")).toList();where+=" AND EXISTS (SELECT 1 FROM metafieldvalue sv JOIN metafieldcategorykey sk ON sk.mtfck_id=sv.mtfck_id JOIN metafieldkey fk ON fk.mtfk_id=sk.mtfk_id LEFT JOIN customer c ON fk.mtfk_type='co2cs' AND c.cs_id=sv.mtfv_value AND c.hq_id IN ("+marks(allowed.size())+") LEFT JOIN company co ON co.cmp_id=c.cmp_id AND co.cmp_visible=1 AND co.cmp_authenticated=1 WHERE sv.mtfv_id=v.mtfv_id AND sk.mtfc_id=1 AND sk.hq_id=ck.hq_id AND (sv.mtfv_value LIKE ? OR (co.cmp_id IS NOT NULL AND (c.cs_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_comp2 LIKE ?))))";String like=(q.prefix()?"":"%")+term.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";args.addAll(allowed);for(int i=0;i<4;i++)args.add(like);} if(q.letter()!=null&&!q.letter().isEmpty()){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue lv JOIN metafieldcategorykey lk ON lk.mtfck_id=lv.mtfck_id JOIN metafieldkey k ON k.mtfk_id=lk.mtfk_id WHERE lv.mtfv_id=v.mtfv_id AND lk.mtfc_id=1 AND lk.hq_id=ck.hq_id AND (k.mtfk_name='Name' OR k.mtfk_type='surname') AND lv.mtfv_value LIKE ?)";args.add(q.letter()+"%");} 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 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());for(long cid:ids){var values=rows.stream().filter(r->id(r,"id")==cid).toList();result.add(fields("id",cid,"headquartersId",hq,"fields",values));}} + 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"))); - result.sort(Comparator.comparing(r->contactName((List>)r.get("fields")),String.CASE_INSENSITIVE_ORDER));return result; + 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; } 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());} - for(var d:defs){String v=s.values().getOrDefault(id(d,"id"),"");if(v==null)v="";v=v.strip();if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Kontaktfeld ist zu lang.");String type=text(d,"type");String oldValue=before.stream().filter(r->id(r,"mtfck_id")==id(d,"id")).map(r->text(r,"mtfv_value")).findFirst().orElse("");boolean modified=!v.equals(oldValue);if(modified&&type.equals("co2cs")&&!v.isBlank()){try{scope.customer(u,Long.parseLong(v));}catch(NumberFormatException e){throw bad("Bitte einen Kunden auswählen.");}} + for(var d:defs){String v=s.values().getOrDefault(id(d,"id"),"");if(v==null)v="";String oldValue=before.stream().filter(r->id(r,"mtfck_id")==id(d,"id")).map(r->text(r,"mtfv_value")).findFirst().orElse("");if(!v.equals(oldValue))v=v.strip();if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Kontaktfeld ist zu lang.");String type=text(d,"type");boolean modified=!v.equals(oldValue);if(modified&&type.equals("co2cs")&&!v.isBlank()){try{scope.customer(u,Long.parseLong(v));}catch(NumberFormatException e){throw bad("Bitte einen Kunden auswählen.");}} + 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."); diff --git a/backend/src/main/java/de/stadtbote/votian/ContactVcard.java b/backend/src/main/java/de/stadtbote/votian/ContactVcard.java new file mode 100644 index 0000000..f631d25 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ContactVcard.java @@ -0,0 +1,45 @@ +package de.stadtbote.votian; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.DateTimeException; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; + +/** vCard 3.0 export of the reviewed contact, independent of an SMTP connection. */ +final class ContactVcard { + record Field(String label,String type,String value){} + record Layout(List header,List address){} + static List positions(String value,int maximum){ + if(value==null||value.isBlank())return List.of(); + if(!value.matches("[0-9]{1,3}(?:,[0-9]{1,3})*")||value.split(",").length>maximum)throw bad("Ungültige Kontakt-Feldzuordnung."); + 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 at(List fields,List indexes,int position){return position fields,Layout layout,List companies){ + String family=layout.header().isEmpty()?named(fields,"Name","Nachname"):at(fields,layout.header(),0); + String given=layout.header().isEmpty()?named(fields,"Vorname"):at(fields,layout.header(),1); + String full=(given+" "+family).strip();if(full.isEmpty())full="Kontakt "+id; + var lines=new ArrayList(List.of("BEGIN:VCARD","VERSION:3.0","N:"+escape(family)+";"+escape(given)+";;;","FN:"+escape(full))); + String[] address=layout.address().isEmpty()?new String[]{named(fields,"Strasse","Straße"),named(fields,"Hausnr.","Hausnummer"),named(fields,"PLZ"),named(fields,"Ort"),named(fields,"Land")}:java.util.stream.IntStream.range(0,5).mapToObj(i->at(fields,layout.address(),i)).toArray(String[]::new); + if(Arrays.stream(address).anyMatch(v->!v.isBlank()))lines.add("ADR;TYPE=WORK:;;"+escape((address[0]+" "+address[1]).strip())+";"+escape(address[3])+";;"+escape(address[2])+";"+escape(address[4])); + companies.stream().filter(v->!v.isBlank()).distinct().forEach(v->lines.add("ORG:"+escape(v))); + String title=named(fields,"Position"),note=named(fields,"Bemerkung");if(!title.isBlank())lines.add("TITLE:"+escape(title));if(!note.isBlank())lines.add("NOTE:"+escape(note)); + var seen=new HashSet();boolean bday=false; + for(var f:fields){String v=f.value(),t=f.type(),kind=t.endsWith("private")?"HOME":"WORK";if(v.isBlank())continue; + if(email(t)){for(String item:v.split(",")){String mail=item.strip();if(!mail.isBlank()){String line="EMAIL;TYPE="+kind+":"+escape(mail);if(seen.add(line))lines.add(line);}}} + if(t.matches("(?:phone|mobile|fax)(?:_business|_private)?"))lines.add("TEL;TYPE="+kind+","+(t.startsWith("mobile")?"CELL":t.startsWith("fax")?"FAX":"VOICE")+":"+escape(v)); + if(Set.of("url","url_business","url_private").contains(t)&&!MasterForms.safeLink("",v).isEmpty()&&!v.contains("\r")&&!v.contains("\n"))lines.add("URL:"+MasterForms.safeLink("",v)); + if(t.equals("birthdate")&&!bday){String date=birthday(v);if(!date.isEmpty()){lines.add("BDAY:"+date);bday=true;}} + } + lines.add("END:VCARD");return lines.stream().map(ContactVcard::fold).collect(java.util.stream.Collectors.joining("\r\n"))+"\r\n"; + } + // Fold at 75 UTF-8 octets, never through a multibyte character. + static String fold(String line){var out=new StringBuilder();int length=0;for(int cp:line.codePoints().toArray()){String text=new String(Character.toChars(cp));int bytes=text.getBytes(StandardCharsets.UTF_8).length;if(length+bytes>75){out.append("\r\n ");length=1;}out.append(text);length+=bytes;}return out.toString();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ContactVcards.java b/backend/src/main/java/de/stadtbote/votian/ContactVcards.java new file mode 100644 index 0000000..49bf7fc --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ContactVcards.java @@ -0,0 +1,79 @@ +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.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +@RestController +@RequestMapping("/api/crm/contacts/{contact}/vcard") +class ContactVcards { + record Document(long headquarters,long contact,String filename,String content,String revision){} + record Draft(String revision,String recipients){} + record Confirmation(boolean confirmed){} + record Pending(long actor,long home,long workplace,Document document,List recipients,String from,Instant expires){} + record Delivery(long home,long workplace,long headquarters,long contact,String filename,List recipients,String from,String startedAt,String state){} + final DatabaseService db;final ContactService contacts;final BusinessSupport support;final AppointmentMailTransport transport; + final Map pending=new LinkedHashMap<>(); + ContactVcards(DatabaseService db,ContactService contacts,BusinessSupport support,AppointmentMailTransport transport){this.db=db;this.contacts=contacts;this.support=support;this.transport=transport;} + @GetMapping Map view(Authentication auth,@PathVariable long contact,@RequestParam long headquarters){return view(ApiController.user(auth),headquarters,contact);} + @PostMapping("/mail/preview") Map preview(Authentication auth,@PathVariable long contact,@RequestParam long headquarters,@RequestBody Draft draft){return preview(ApiController.user(auth),headquarters,contact,draft);} + @PostMapping("/mail/{token}") Map send(Authentication auth,@PathVariable long contact,@RequestParam long headquarters,@PathVariable String token,@RequestBody Confirmation confirmation){return send(ApiController.user(auth),headquarters,contact,token,confirmation);} + @GetMapping("/mail") Map history(Authentication auth,@PathVariable long contact,@RequestParam long headquarters){return history(ApiController.user(auth),headquarters,contact);} + @GetMapping("/mail/{token}") Map status(Authentication auth,@PathVariable long contact,@RequestParam long headquarters,@PathVariable String token){return status(ApiController.user(auth),headquarters,contact,token);} + @SuppressWarnings("unchecked") Document document(LegacyUser u,long headquarters,long contact){ + var detail=contacts.get(u,headquarters,contact);var schema=(Map)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()){ + 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()); + } + var layout=new ContactVcard.Layout(ContactVcard.positions(new OrderEntryCatalog(db).parameter(0,"MASK_CONTACT_HEADER_FIELDS_DISPLAYED"),2),ContactVcard.positions(new OrderEntryCatalog(db).parameter(0,"MASK_CONTACT_ADDRESS_FIELDS_DISPLAYED"),5)); + String content=ContactVcard.create(contact,fs,layout,companies);if(content.getBytes(StandardCharsets.UTF_8).length>1_000_000)throw bad("Die vCard ist zu groß. Bitte Kontaktfelder prüfen."); + return new Document(headquarters,contact,"Kontakt-"+contact+".vcf",content,revision(List.of(detail.get("revision"),schema.get("revision"),content,layout))); + } + Map view(LegacyUser u,long headquarters,long contact){return db.readTransaction(()->{ + var d=document(u,headquarters,contact);var users=db.queryForList("SELECT usr_email FROM user WHERE usr_id=?",u.userId());if(users.size()>1)throw bad("Mehrdeutiger Benutzer."); + return fields("filename",d.filename(),"content",d.content(),"revision",d.revision(),"defaultRecipient",users.isEmpty()?"":text(users.getFirst(),"usr_email"),"mailConfigured",transport.configured()); + });} + String sender(LegacyUser u){String from=Objects.toString(transport.from,"");if(from.isBlank()){var c=new OrderEntryCatalog(db);from=c.parameter(u.headquartersId(),"MAIL_NEWSLETTER_SENDER_ADDRESS");if(from.isBlank())from=c.parameter(u.headquartersId(),"MAIL_SENDER_ADDRESS");}return AppointmentNotifications.address(from);} + Map preview(LegacyUser u,long headquarters,long contact,Draft draft){ + var document=db.readTransaction(()->document(u,headquarters,contact));if(draft==null||!document.revision().equals(draft.revision()))throw bad("Der Kontakt wurde geändert. Bitte die vCard neu laden."); + if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");var recipients=NewsletterDispatch.addresses(draft.recipients());if(recipients.isEmpty()||recipients.size()>20)throw bad("Bitte ein bis zwanzig E-Mail-Empfänger eingeben."); + String from=sender(u),token=UUID.randomUUID().toString();var p=new Pending(u.userId(),u.originalHeadquartersId(),u.headquartersId(),document,recipients,from,Instant.now().plusSeconds(900)); + synchronized(pending){pending.values().removeIf(v->v.expires().isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele vCard-Vorschauen. Bitte später erneut versuchen.");pending.put(token,p);} + return fields("token",token,"from",from,"recipients",recipients,"subject","VCard","filename",document.filename(),"content",document.content()); + } + static String context(LegacyUser u,long hq,long contact){return revision(List.of(u.originalHeadquartersId(),u.headquartersId(),hq,contact));} + Delivery decode(Map row){try{return support.json.readValue(text(row,"gdc_content"),Delivery.class);}catch(Exception e){throw bad("Der Versandstatus ist nicht lesbar. Bitte vor erneutem Versand prüfen lassen.");}} + Delivery stored(LegacyUser u,long hq,long contact,String token){ + BusinessSupport.token(token);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngvcardmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),token);if(rows.isEmpty())return null;var d=decode(one(rows)); + if(d.home()!=u.originalHeadquartersId()||d.workplace()!=u.headquartersId()||d.headquarters()!=hq||d.contact()!=contact)throw bad("Die Versandkennung gehört zu einem anderen Kontakt oder Arbeitsplatz.");return d; + } + static Map result(String token,Delivery d){return fields("token",token,"state",d.state(),"recipients",d.recipients(),"from",d.from(),"filename",d.filename(),"startedAt",d.startedAt(),"message",d.state().equals("SENT")?"Die vCard wurde vom Mailserver angenommen. Dies bestätigt noch nicht die Zustellung.":"Der Versand wurde begonnen, aber nicht sicher bestätigt. Er wird nicht automatisch wiederholt. Bitte den Empfang oder das Mailprotokoll prüfen.");} + Map history(LegacyUser u,long headquarters,long contact){contacts.get(u,headquarters,contact);return fields("items",db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngvcardmail' AND gdc_obj_id=? AND gdc_context=? ORDER BY gdc_id DESC LIMIT 10",u.userId(),context(u,headquarters,contact)).stream().map(r->result(text(r,"gdc_gen_fieldname"),decode(r))).toList());} + Map status(LegacyUser u,long headquarters,long contact,String token){contacts.get(u,headquarters,contact);var d=stored(u,headquarters,contact,token);if(d==null)throw bad("Für diese Kennung wurde noch kein Versand gestartet.");return result(token,d);} + Map send(LegacyUser u,long headquarters,long contact,String token,Confirmation confirmation){ + contacts.get(u,headquarters,contact);BusinessSupport.token(token);if(confirmation==null||!confirmation.confirmed())throw bad("Bitte Empfänger und vCard-Inhalt bestätigen."); + var prior=stored(u,headquarters,contact,token);if(prior!=null)return result(token,prior); + Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.actor()!=u.userId()||p.home()!=u.originalHeadquartersId()||p.workplace()!=u.headquartersId()||p.document().headquarters()!=headquarters||p.document().contact()!=contact||p.expires().isBefore(Instant.now()))throw bad("Die Versandvorschau ist abgelaufen. Bitte zuerst den Versandverlauf prüfen und danach neu vorbereiten."); + support.transactional("genericdatacontainer");boolean claimed=db.contactVcardMailWriteTransaction(()->{ + if(stored(u,headquarters,contact,token)!=null)return false; + if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert."); + var current=document(u,headquarters,contact);if(!current.revision().equals(p.document().revision())||!sender(u).equals(p.from()))throw bad("Kontakt, Feldzuordnung oder Absender wurde geändert. Bitte die vCard neu laden."); + var d=new Delivery(p.home(),p.workplace(),headquarters,contact,p.document().filename(),p.recipients(),p.from(),LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString(),"STARTED"); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngvcardmail","gdc_obj_id",u.userId(),"gdc_gen_fieldname",token,"gdc_context",context(u,headquarters,contact),"gdc_content",support.encode(d))); + support.audit(u,0,0,0,203,"vCard-Versand für Kontakt "+contact+" vorgemerkt",token);return true; + }); + if(!claimed)return result(token,stored(u,headquarters,contact,token)); + String state="SENT";try{transport.sendVcard(p.from(),p.recipients(),p.document().filename(),p.document().content().getBytes(StandardCharsets.UTF_8));}catch(RuntimeException e){state="UNCERTAIN";} + var d=stored(u,headquarters,contact,token);var done=new Delivery(d.home(),d.workplace(),d.headquarters(),d.contact(),d.filename(),d.recipients(),d.from(),d.startedAt(),state); + db.writeTransaction(()->db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='ngvcardmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(done),u.userId(),token));return result(token,done); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierAvailability.java b/backend/src/main/java/de/stadtbote/votian/CourierAvailability.java new file mode 100644 index 0000000..4d89ba1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierAvailability.java @@ -0,0 +1,28 @@ +package de.stadtbote.votian; + +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.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** The stored online indicator from the current courier_special.php. */ +@RestController +@RequestMapping("/api/master-data/couriers/{courier}/availability") +class CourierAvailability { + final DatabaseService db; final MasterData masters; + CourierAvailability(DatabaseService db,MasterData masters){this.db=db;this.masters=masters;} + @GetMapping Object get(Authentication a,@PathVariable long courier){return view(ApiController.user(a),courier);} + Map view(LegacyUser actor,long courier){actor.require(1);return db.readTransaction(()->{ + var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(1); + var row=masters.load(u,"couriers",courier,false);boolean online=id(row,"cr_available")==1; + String vehicle="",warning=""; + if(online){ + var types=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(row,"vht_id")); + if(types.size()==1&&!text(types.getFirst(),"mt_value").isBlank())vehicle=text(types.getFirst(),"mt_value"); + else warning="Der aktuelle Fahrzeugtyp ist nicht eindeutig hinterlegt."; + } + return fields("online",online,"vehicleType",vehicle,"warning",warning); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierBourse.java b/backend/src/main/java/de/stadtbote/votian/CourierBourse.java new file mode 100644 index 0000000..f8c8ba7 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierBourse.java @@ -0,0 +1,53 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** courier/brs_jb_list.php: customer-granted orders in status 9, independent of own assignments. */ +@RestController +@RequestMapping("/api/courier-portal/bourse") +class CourierBourse { + record Query(LocalDate from,LocalDate to,Long numberFrom,Long numberTo,List headquarters,long vehicleType,Map filters,String sort,boolean descending,int page){} + record Detail(long job){} + record Context(long courier,List headquarters,List columns,int limit,boolean yesterday,boolean numberMode,boolean refreshMode,String warning,Map expressions){} + static final String HQ_SQL="SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?"; + static final List DEFAULT=List.of("jb_id","business_volume","jb_cr_price","tr_comp","tr_comp2","streethsno","jb_tourzipcodes","vht_value","jb_ordertime"); + static final Map LABELS=Map.ofEntries(Map.entry("jb_id","Nr."),Map.entry("business_volume","Preis"),Map.entry("tr_comp","Firma"),Map.entry("tr_comp2","Zusatz"),Map.entry("streethsno","Startadresse"),Map.entry("jb_tourzipcodes","PLZn"),Map.entry("jb_tourcities","Orte"),Map.entry("vht_value","Fahrzeug"),Map.entry("jb_ordertime","Auftragszeit"),Map.entry("cr_sid","Kurier"),Map.entry("jb_latesttaketime","Spät. Annahmezeit"),Map.entry("jb_taketime","Annahme"),Map.entry("jb_cr_price","Fuhrlohn"),Map.entry("ad_zipcode","Start-PLZ"),Map.entry("jb_lastzipcode","Ziel-PLZ"),Map.entry("jb_dispoinfo","Dispo-Info")); + static final Map FILTERS=Map.of("street","a.ad_street","zip","a.ad_zipcode","city","a.ad_city","company","t.tr_comp","person","t.tr_person","remark","t.tr_remark"); + final DatabaseService db; + CourierBourse(DatabaseService db){this.db=db;} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @PostMapping("/detail") Object detail(Authentication a,@RequestBody Detail q){return detail(ApiController.user(a),q);} + 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; + 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")); + return new Context(id(account,"cr_id"),headquarters,keys.stream().map(k->new OrderListCells.Column(k,LABELS.get(k),OrderListCells.type(k))).toList(),limit,catalog.enabled(hq,"MASK_BRSJB_LIST_STARTDATE_YESTERDAY"),catalog.enabled(hq,"MASK_BRSJB_LIST_MODE_JOB_NUM"),catalog.enabled(hq,"MASK_BRSJB_LIST_MODE_REFRESH"),unknown?"Unbekannte alte Spalten wurden ausgelassen.":"",expr); + } + Map options(LegacyUser u){return db.readTransaction(()->{var c=context(u);var hq=db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id IN ("+CourierPortal.placeholders(c.headquarters().size())+") ORDER BY hq_mnemonic,hq_id",c.headquarters().toArray());var types=db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort ORDER BY mt_sort");return fields("headquarters",hq,"vehicleTypes",types,"columns",c.columns(),"limit",c.limit(),"from",LocalDate.now().minusDays(c.yesterday()?1:0),"to",LocalDate.now().plusDays(1),"copyNumber",c.numberMode(),"refreshToggle",c.refreshMode(),"warning",c.warning());});} + static String membership(){return " FROM job jb WHERE jb.jb_status=9 AND EXISTS (SELECT 1 FROM metatype vt WHERE vt.mt_type='vehicletype' AND vt.mt_sort=jb.vht_id) AND EXISTS (SELECT 1 FROM tour ft JOIN address fa ON fa.ad_id=ft.ad_id WHERE ft.jb_id=jb.jb_id AND ft.tr_sort=1) AND EXISTS (SELECT 1 FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN customercourier rel ON rel.cs_id=cc.cs_id WHERE cc.csc_id=jb.csc_id_payer AND rel.cr_id=? AND rel.cscr_relation=1 AND cs.hq_id IN ("+HQ_SQL+")) AND jb.hq_id IN ("+HQ_SQL+")";} + Map query(LegacyUser u,Query q){return db.readTransaction(()->{ + var c=context(u);if(q==null||q.page()<0||q.page()>9||q.headquarters()==null||q.headquarters().size()>500||!c.headquarters().containsAll(q.headquarters())||q.filters()==null||!FILTERS.keySet().containsAll(q.filters().keySet())||q.vehicleType()<0||q.sort()==null||!q.sort().equals("jb_ordertime")&&c.columns().stream().noneMatch(col->col.key().equals(q.sort())))throw bad("Ungültige Börsensuche."); + boolean numbers=q.numberFrom()!=null||q.numberTo()!=null;if(numbers&&(q.numberFrom()!=null&&q.numberFrom()<1||q.numberTo()!=null&&q.numberTo()<1||q.numberFrom()!=null&&q.numberTo()!=null&&q.numberFrom()>q.numberTo()))throw bad("Ungültiger Auftragsnummernbereich."); + if(!numbers&&(q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen."); + if(q.headquarters().isEmpty())return fields("rows",List.of(),"columns",c.columns(),"page",q.page(),"limit",c.limit(),"hasNext",false,"warning",c.warning()); + long md=mandator(db,u);var args=new ArrayList(List.of(c.courier(),md,md));var sql=new StringBuilder(membership());sql.append(" AND jb.hq_id IN (").append(CourierPortal.placeholders(q.headquarters().size())).append(')');args.addAll(q.headquarters()); + if(q.vehicleType()>0){if(db.queryForList("SELECT mt_id FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",q.vehicleType()).size()!=1)throw bad("Ungültiger Fahrzeugtyp.");sql.append(" AND jb.vht_id=?");args.add(q.vehicleType());}else sql.append(" AND jb.vht_id<=100"); + if(numbers){if(q.numberFrom()!=null){sql.append(" AND jb.jb_id>=?");args.add(q.numberFrom());}if(q.numberTo()!=null){sql.append(" AND jb.jb_id<=?");args.add(q.numberTo());}}else{sql.append(" AND jb.jb_ordertime>=? AND jb.jb_ordertime(q.filters().keySet())){String value=q.filters().get(key);if(value!=null&&!value.isBlank()){sql.append(" AND EXISTS (SELECT 1 FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND ").append(FILTERS.get(key)).append(" LIKE ? ESCAPE '!')");args.add(EntryCostCenterSearch.term(value,false));}} + var projection=new ArrayList();for(var col:c.columns())projection.add(c.expressions().get(col.key())+" AS cell_"+col.key()); + args.add(c.limit()+1);args.add(q.page()*c.limit());var data=db.queryForList("SELECT jb.jb_id,"+String.join(",",projection)+sql+" ORDER BY "+c.expressions().get(q.sort())+(q.descending()?" DESC":" ASC")+",jb.jb_id LIMIT ? OFFSET ?",args.toArray()); + var rows=data.stream().limit(c.limit()).map(r->{var cells=new LinkedHashMap();for(var col:c.columns()){String k=col.key(),v=text(r,"cell_"+k);if(col.type().equals("date"))v=dateTime(r,"cell_"+k)==null?"":dateTime(r,"cell_"+k).toString();else if(k.equals("jb_tourzipcodes")||k.equals("jb_tourcities")){var parts=v.split("\\|",-1);int idx=k.equals("jb_tourcities")?1:0;v=parts.length>idx?parts[idx].replaceAll(";+$","").replace(';',','):"";}cells.put(k,v);}return fields("id",id(r,"jb_id"),"cells",cells);}).toList();return fields("rows",rows,"columns",c.columns(),"page",q.page(),"limit",c.limit(),"hasNext",data.size()>c.limit()&&q.page()<9,"warning",c.warning()); + });} + Map detail(LegacyUser u,Detail q){return db.readTransaction(()->{var c=context(u);if(q==null||q.job()<1)throw bad("Auftrag fehlt.");long md=mandator(db,u);var jobs=db.queryForList("SELECT jb.jb_id,jb.jb_ordertime,jb.jb_taketime,jb.jb_totalprice,jb.jb_cr_price,jb.jb_dispoinfo,jb.vht_id"+membership()+" AND jb.jb_id=?",c.courier(),md,md,q.job());if(jobs.size()!=1)throw CourierPortal.denied();var job=jobs.getFirst();var stops=db.queryForList("SELECT t.tr_sort,t.tr_comp,t.tr_comp2,t.tr_hsno,t.tr_person,t.tr_remark,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,t.tr_id",q.job());return fields("id",q.job(),"ordered",dateTime(job,"jb_ordertime"),"price",BillingService.money(decimal(job,"jb_totalprice")),"freight",BillingService.money(decimal(job,"jb_cr_price")),"information",text(job,"jb_dispoinfo"),"stops",stops.stream().map(s->fields("sequence",id(s,"tr_sort"),"company",text(s,"tr_comp"),"additional",text(s,"tr_comp2"),"address",(text(s,"ad_street")+" "+text(s,"tr_hsno")+", "+text(s,"ad_zipcode")+" "+text(s,"ad_city")).strip(),"person",text(s,"tr_person"),"remark",text(s,"tr_remark"))).toList());});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDevicePhone.java b/backend/src/main/java/de/stadtbote/votian/CourierDevicePhone.java new file mode 100644 index 0000000..ae62aed --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDevicePhone.java @@ -0,0 +1,70 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +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; + +/** PHP's MASK_CR_MOBILE_PDA_EDIT and sysadmin.conf_prop association. */ +@RestController +@RequestMapping("/api/master-data/couriers/{courier}/device-phone") +class CourierDevicePhone { + final DatabaseService db;final BusinessSupport support;final MasterData masters;final boolean enabled;final Clock clock; + @org.springframework.beans.factory.annotation.Autowired + CourierDevicePhone(DatabaseService db,BusinessSupport support,MasterData masters,@Value("${app.device-phone-sync.enabled:true}")boolean enabled){this(db,support,masters,enabled,Clock.systemUTC());} + CourierDevicePhone(DatabaseService db,BusinessSupport support,MasterData masters,boolean enabled,Clock clock){this.db=db;this.support=support;this.masters=masters;this.enabled=enabled;this.clock=clock;} + record Save(String requestId,String revision,long selected,String phone,boolean transfer){} + record Retry(String requestId,String revision,long pending){} + record Pending(long headquarters,long courier,long user,long actor,long employee,String request,DatabaseService.DevicePhoneChange change,int attempts,String error){} + record State(Map courier,boolean editable,Map pending){} + @GetMapping Object get(Authentication a,@PathVariable long courier){return view(ApiController.user(a),courier);} + @PutMapping Object save(Authentication a,@PathVariable long courier,@RequestBody Save r){return save(ApiController.user(a),courier,r);} + @PostMapping("/retry") Object retry(Authentication a,@PathVariable long courier,@RequestBody Retry r){return retry(ApiController.user(a),courier,r);} + boolean editable(long hq){return new OrderEntryCatalog(db).parameter(hq,"MASK_CR_MOBILE_PDA_EDIT").equals("1");} + Map pending(long courier,boolean lock){String sql="SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngphone' AND gdc_obj_id=? AND gdc_context<>'DONE' ORDER BY gdc_id";var rows=lock?db.lockRows(sql,courier):db.queryForList(sql,courier);if(rows.size()>1)throw bad("Mehrere offene Gerätetelefon-Vormerkungen. Bitte den Bestand prüfen.");return rows.isEmpty()?Map.of():rows.getFirst();} + State state(LegacyUser u,long courier,boolean lock){masters.access(u,"couriers");var raw=masters.load(u,"couriers",courier,lock);var cr=fields("cr_id",courier,"hq_id",u.headquartersId(),"usr_id",raw.get("usr_id"),"cr_eid",raw.get("cr_eid"),"cmp_comp",raw.get("cmp_comp"),"cr_mobile_pda",text(raw,"cr_mobile_pda"));var person=one(db.queryForList("SELECT usr_id,hq_id,usr_type FROM user WHERE usr_id=?",id(cr,"usr_id")));if(id(person,"hq_id")!=u.headquartersId()||id(person,"usr_type")!=3||db.queryForList("SELECT cr_id FROM courier WHERE usr_id=?",id(cr,"usr_id")).size()!=1)throw bad("Das Fahrerkonto ist nicht eindeutig zugeordnet.");return new State(cr,editable(u.headquartersId()),pending(courier,lock));} + Pending decode(Map row){try{var p=support.json.readValue(text(row,"gdc_content"),Pending.class);if(p.courier()!=id(row,"gdc_obj_id")||p.headquarters()<=0||p.user()<=0||p.change().user()!=p.user()||p.change().headquarters()!=p.headquarters()||!p.request().equals(text(row,"gdc_gen_fieldname")))throw new IllegalArgumentException();return p;}catch(Exception e){throw bad("Die Gerätetelefon-Vormerkung ist ungültig.");}} + List> options(State s){var rows=db.devicePhoneOptions(MetaObjectRegistration.instance(db),id(s.courier(),"hq_id"),id(s.courier(),"usr_id"));if(rows.size()>500||rows.stream().anyMatch(r->text(r,"cp_secval").length()>30)||rows.stream().map(r->text(r,"cp_secval")).distinct().count()!=rows.size())throw bad("Die Gerätetelefon-Auswahl ist zu groß oder mehrdeutig.");return rows;} + List> localOwners(long courier,List phones,boolean lock){if(phones.isEmpty())return List.of();var args=new ArrayList();args.add(courier);args.addAll(phones);String sql="SELECT cr.cr_id,cr.hq_id,cr.usr_id,cr.cr_mobile_pda,cr.cr_eid,cmp.cmp_comp,h.hq_mnemonic FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN headquarters h ON h.hq_id=cr.hq_id WHERE cr.cr_id<>? AND cr.cr_mobile_pda IN ("+CourierPortal.placeholders(phones.size())+") ORDER BY cr.cr_id";return lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());} + Set allowed(LegacyUser u){return new HashSet<>(MasterDirectoryScope.access(db,u,1).branches().stream().map(r->id(r,"id")).toList());} + List> displayOptions(LegacyUser u,long courier,State s){var choices=options(s);if(choices.isEmpty())return List.of();var owners=localOwners(courier,choices.stream().map(r->text(r,"cp_secval")).toList(),false);var permitted=allowed(u);return choices.stream().>map(r->{String phone=text(r,"cp_secval");var matches=owners.stream().filter(x->phone.equals(text(x,"cr_mobile_pda"))).toList();boolean available=matches.stream().allMatch(x->permitted.contains(id(x,"hq_id")));return fields("id",id(r,"cp_id"),"label",phone,"available",available,"requiresTransfer",!matches.isEmpty(),"assignment",available?String.join(", ",matches.stream().map(x->CourierDriverProfile.label(x)+" · "+text(x,"hq_mnemonic")).toList()):"Lokal in einer nicht freigegebenen Niederlassung zugeordnet");}).toList();} + Map view(LegacyUser u,long courier){var s=db.readTransaction(()->state(u,courier,false));var result=fields("id",courier,"label",CourierDriverProfile.label(s.courier()),"phone",text(s.courier(),"cr_mobile_pda"),"editable",s.editable(),"revision",revision(s),"enabled",enabled,"options",List.of(),"pending",null,"optionsError",""); + if(!s.pending().isEmpty()){var p=decode(s.pending());String raw=text(s.pending(),"gdc_context"),status=raw.startsWith("SENDING:")?"SENDING":raw.startsWith("RETRY:")?"RETRY":raw;result.put("pending",fields("id",id(s.pending(),"gdc_id"),"phone",p.change().desired(),"state",status,"attempts",p.attempts(),"message",p.error(),"revision",revision(s.pending())));} + else if(!s.editable())try{result.put("options",displayOptions(u,courier,s));}catch(RuntimeException e){result.put("optionsError","Die zentrale Gerätetelefon-Auswahl ist derzeit nicht verfügbar. Bitte erneut laden.");} + return result; + } + Map save(LegacyUser u,long courier,Save r){masters.access(u,"couriers");if(r==null||r.phone()==null||r.selected()<0)throw bad("Gerätetelefon fehlt.");BusinessSupport.token(r.requestId());String phone=EmployeeAdministration.optional(r.phone(),30,"Gerätetelefon");if(!phone.matches("[+0-9() /-]*"))throw bad("Das Gerätetelefon enthält ungültige Zeichen.");Object intent=List.of("COURIER_DEVICE_PHONE",courier,r); + var initial=db.readTransaction(()->{if(support.receipt(u,r.requestId(),intent)!=null)return null;var s=state(u,courier,false);unchanged(s,r.revision());if(!s.pending().isEmpty())throw bad("Bitte zuerst den offenen Geräteabgleich abschließen.");return s;});if(initial==null)return view(u,courier); + DatabaseService.DevicePhoneChange change; + if(initial.editable()){if(r.selected()!=0)throw bad("Die freie Telefonpflege benötigt keine Geräteauswahl.");change=null;} + else{if(!enabled)throw bad("Der zentrale Geräteabgleich ist ausgeschaltet.");var choices=options(initial);if(r.selected()==0&&!phone.isBlank()||r.selected()>0&&choices.stream().noneMatch(x->id(x,"cp_id")==r.selected()&&text(x,"cp_secval").equals(phone)))throw bad("Bitte ein verfügbares Gerätetelefon wählen.");change=new DatabaseService.DevicePhoneChange(MetaObjectRegistration.instance(db),u.headquartersId(),id(initial.courier(),"usr_id"),r.selected(),text(initial.courier(),"cr_mobile_pda"),phone,db.devicePhoneSourceIdentity());} + support.transactional("headquarters","courier","genericdatacontainer");db.writeTransaction(()->{one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));if(support.receipt(u,r.requestId(),intent)!=null)return null;var s=state(u,courier,true);unchanged(s,r.revision()); + var displaced=phone.isBlank()?List.>of():localOwners(courier,List.of(phone),true); + if(!displaced.isEmpty()){ + var branches=allowed(u);if(displaced.stream().anyMatch(x->!branches.contains(id(x,"hq_id"))))throw bad("Diese Nummer ist einem Transporteur außerhalb der freigegebenen Niederlassungen zugeordnet."); + if(!r.transfer())throw bad("Bitte das Lösen der bisherigen lokalen Telefonzuordnung bei "+String.join(", ",displaced.stream().map(CourierDriverProfile::label).toList())+" ausdrücklich bestätigen."); + for(var owner:displaced){if(!pending(id(owner,"cr_id"),true).isEmpty())throw bad("Für einen bisherigen Transporteur läuft bereits ein Geräteabgleich.");db.update("UPDATE courier SET cr_mobile_pda='' WHERE cr_id=?",id(owner,"cr_id"));var actor=new LegacyUser(u.userId(),u.employeeId(),id(owner,"hq_id"),u.username(),u.rights());support.audit(actor,0,0,id(owner,"cr_id"),117,"MSISDN_OLD="+phone+"|MSISDN_NEW=|TRANSFER_TO="+courier,r.requestId());} + } + if(change==null){db.update("UPDATE courier SET cr_mobile_pda=? WHERE cr_id=?",phone,courier);support.audit(u,0,0,courier,117,"MSISDN_OLD="+text(s.courier(),"cr_mobile_pda")+"|MSISDN_NEW="+phone,r.requestId());} + else{if(change.instance()!=MetaObjectRegistration.instance(db))throw bad("Die Datenbankinstanz wurde geändert. Bitte neu laden.");var p=new Pending(u.headquartersId(),courier,change.user(),u.userId(),u.employeeId(),r.requestId(),change,0,"");db.insert("genericdatacontainer",fields("gdc_obj_type","ngphone","gdc_obj_id",courier,"gdc_gen_fieldname",r.requestId(),"gdc_context","PENDING","gdc_content",support.encode(p)));} + support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier); + } + boolean current(Pending p){var rows=db.queryForList("SELECT usr_id,hq_id,cr_mobile_pda FROM courier WHERE cr_id=?",p.courier());return rows.size()==1&&id(rows.getFirst(),"usr_id")==p.user()&&id(rows.getFirst(),"hq_id")==p.headquarters()&&text(rows.getFirst(),"cr_mobile_pda").equals(p.change().previous())&&!editable(p.headquarters())&&MetaObjectRegistration.instance(db)==p.change().instance();} + boolean active(long row,Pending p){var rows=db.queryForList("SELECT gdc_context FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngphone'",row);return rows.size()==1&&text(rows.getFirst(),"gdc_context").startsWith("SENDING:")&¤t(p);} + void update(long row,Pending p,String context){db.update("UPDATE genericdatacontainer SET gdc_context=?,gdc_content=? WHERE gdc_id=?",context,support.encode(p),row);} + void finish(long row,Pending p){support.transactional("courier","genericdatacontainer");db.writeTransaction(()->{var r=one(db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngphone'",row));if(text(r,"gdc_context").equals("DONE"))return null;if(!text(r,"gdc_context").startsWith("SENDING:"))throw new IllegalStateException("Der Geräteabgleich ist nicht mehr aktiv.");db.lockRows("SELECT cr_id FROM courier WHERE cr_id=?",p.courier());if(!current(p))throw new IllegalStateException("Die lokale Zuordnung wurde zwischenzeitlich geändert.");db.update("UPDATE courier SET cr_mobile_pda=? WHERE cr_id=?",p.change().desired(),p.courier());var actor=new LegacyUser(p.actor(),p.employee(),p.headquarters(),"Geräteabgleich","1".repeat(70));support.audit(actor,0,0,p.courier(),117,"MSISDN_OLD="+p.change().previous()+"|MSISDN_NEW="+p.change().desired(),p.request());update(row,p,"DONE");return null;});} + String process(long row){if(!enabled)return "DISABLED";support.transactional("genericdatacontainer");var p=db.writeTransaction(()->{var rows=db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngphone' AND gdc_id=?",row);if(rows.isEmpty())return null;var r=rows.getFirst();String status=text(r,"gdc_context");if(!status.equals("PENDING")&&!(status.startsWith("RETRY:")||status.startsWith("SENDING:"))||!status.equals("PENDING")&&status.compareTo(MetaObjectRegistration.prefix(status.startsWith("RETRY:")?"RETRY":"SENDING",clock.instant().getEpochSecond()+1))>=0)return null;var prior=decode(r);var next=new Pending(prior.headquarters(),prior.courier(),prior.user(),prior.actor(),prior.employee(),prior.request(),prior.change(),Math.min(100000,prior.attempts()+1),"");update(row,next,MetaObjectRegistration.prefix("SENDING",clock.instant().plusSeconds(60).getEpochSecond()));return next;});if(p==null)return "SKIPPED"; + String result="DONE",error="";try{if(!current(p))throw new IllegalStateException();db.applyDevicePhone(p.change(),()->active(row,p));db.confirmDevicePhone(p.change(),()->finish(row,p));} + catch(IllegalStateException|IllegalArgumentException e){result="BLOCKED";error="Die lokale oder zentrale Zuordnung wurde geändert oder ist widersprüchlich. Bitte den Bestand prüfen.";} + catch(RuntimeException e){result="RETRY";error="Der Geräteabgleich konnte noch nicht bestätigt werden. Der gespeicherte Vorgang wird erneut geprüft.";} + if(!result.equals("DONE")){String state=result,message=error;db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_context FROM genericdatacontainer WHERE gdc_id=?",row);if(rows.size()==1&&text(rows.getFirst(),"gdc_context").startsWith("SENDING:"))update(row,new Pending(p.headquarters(),p.courier(),p.user(),p.actor(),p.employee(),p.request(),p.change(),p.attempts(),message),state.equals("RETRY")?MetaObjectRegistration.prefix("RETRY",clock.instant().plusSeconds(Math.min(3600,60L*(1L< retry(LegacyUser u,long courier,Retry r){masters.access(u,"couriers");if(!enabled||r==null)throw bad("Geräteabgleich ist nicht verfügbar.");BusinessSupport.token(r.requestId());support.transactional("headquarters","genericdatacontainer");Object intent=List.of("COURIER_DEVICE_RETRY",courier,r);db.writeTransaction(()->{one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));if(support.receipt(u,r.requestId(),intent)!=null)return null;var s=state(u,courier,true);if(s.pending().isEmpty()||id(s.pending(),"gdc_id")!=r.pending())throw bad("Die Vormerkung ist nicht mehr offen.");unchanged(s.pending(),r.revision());String status=text(s.pending(),"gdc_context");if(!status.equals("BLOCKED")&&!status.startsWith("RETRY:"))throw bad("Der Geräteabgleich läuft bereits.");db.update("UPDATE genericdatacontainer SET gdc_context='PENDING' WHERE gdc_id=?",r.pending());support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier);} + @Scheduled(initialDelay=60000,fixedDelay=15000) public synchronized void run(){if(!enabled)return;long limit=clock.instant().getEpochSecond()+1;for(var r:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngphone' AND (gdc_context='PENDING' OR (gdc_context LIKE 'RETRY:%' AND gdc_context get(Authentication authentication,@RequestParam(required=false) LocalDate day,@RequestParam(defaultValue="0") int offset,@RequestParam(defaultValue="0") long vehicle){if(vehicle<0)throw bad("Ungültiges Fahrzeug.");return view(ApiController.user(authentication),day,offset,vehicle);} + static boolean enabled(DatabaseService db,LegacyUser u,long courier){ + var rows=db.queryForList("SELECT cr_disposition FROM courier WHERE cr_id=? AND usr_id=? AND hq_id=?",courier,u.userId(),u.originalHeadquartersId()); + return rows.size()==1&&id(rows.getFirst(),"cr_disposition")==1; + } + long courier(LegacyUser u){var account=CourierPortalScope.verify(db,u);long courier=id(account,"cr_id");if(!enabled(db,u,courier))throw CourierPortal.denied();return courier;} + Map view(LegacyUser u,LocalDate requested,int offset){return view(u,requested,offset,0);} + Map view(LegacyUser u,LocalDate requested,int offset,long selected){return db.readTransaction(()->{ + long courier=courier(u),hq=u.originalHeadquartersId(); + if(offset<0||offset>500||offset%25!=0)throw bad("Ungültige Fahrzeugseite."); + var disposition=new VehicleDisposition(db,support);var today=disposition.now().toLocalDate();var day=requested==null?today:requested;VehicleDisposition.date(day); + long mandator=mandator(db,u); + var vehicles=db.queryForList("SELECT v.crvh_id AS id,v.crvh_sid AS sid,v.crvh_vh_sign AS registration,c.hq_id AS headquarters FROM courier c JOIN couriervehicle v ON v.crvh_sid=c.cr_sid WHERE c.cr_id_parent=? AND (v.cr_id=? OR v.cr_id=c.cr_id) AND TRIM(v.crvh_sid)<>'' AND COALESCE(v.crvh_modify_status,0)<>3 AND c.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND EXISTS (SELECT 1 FROM vehicleavailability va WHERE va.crvh_id=v.crvh_id AND va.vha_day=? AND (va.vha_blocked & 2)=2) ORDER BY v.crvh_sid,v.crvh_id LIMIT 501",courier,courier,mandator,day); + if(vehicles.stream().map(v->id(v,"id")).distinct().count()!=vehicles.size()||vehicles.stream().map(v->text(v,"sid")).distinct().count()!=vehicles.size())throw bad("Die freigegebenen Fahrzeugkennungen sind nicht eindeutig."); + if(vehicles.size()>500)throw bad("Mehr als 500 Fahrzeuge. Bitte die Fahrzeugzuordnungen klären."); + var shown=selected>0?vehicles.stream().filter(v->id(v,"id")==selected).toList():vehicles.subList(Math.min(offset,vehicles.size()),Math.min(offset+25,vehicles.size())); + if(selected>0&&shown.isEmpty())throw CourierPortal.denied();int units=disposition.units();var result=new ArrayList>(); + if(!shown.isEmpty()){ + var ids=shown.stream().map(v->id(v,"id")).toList();String marks=CourierPortal.placeholders(ids.size()); + var dayArgs=new ArrayList(ids);dayArgs.add(day); + var availability=db.queryForList("SELECT crvh_id,vha_id,vha_weight,vha_position,vha_blocked,vha_lock,vha_remark FROM vehicleavailability WHERE crvh_id IN ("+marks+") AND vha_day=? ORDER BY crvh_id,vha_id",dayArgs.toArray()); + var slotArgs=new ArrayList(ids);slotArgs.add(day.toString());slotArgs.add(day.plusDays(1).toString()); + String slotWhere="crvh_id IN ("+marks+") AND vhd_timeslot>=? AND vhd_timeslot36000)throw bad("Die Fahrzeugbelegung überschreitet das vollständige Tagesraster."); + var jobArgs=new ArrayList(List.of(mandator));jobArgs.addAll(slotArgs);jobArgs.add(day.atStartOfDay());jobArgs.add(day.plusDays(1).atStartOfDay());jobArgs.addAll(ids);jobArgs.add(courier);jobArgs.add(courier); + String visible=new OrderEntryCatalog(db).enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")?"":" AND EXISTS (SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname='hide' AND g.gdc_content='1')"; + var jobs=db.queryForList("SELECT j.jb_id,j.jb_status,j.jb_offer,j.jb_ordertime,j.jb_timeunits,CASE WHEN j.jb_status=1 THEN (SELECT c.cr_sid FROM courier c WHERE c.cr_id=j.cr_id) ELSE j.cr_sid END AS actual_sid FROM job j WHERE j.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND j.jb_timeunits>0 AND COALESCE(j.jb_storno,0) IN (0,1,3) AND (j.jb_id IN (SELECT jb_id FROM vehicledisposition WHERE "+slotWhere+" AND jb_id>0) OR (j.jb_ordertime>=? AND j.jb_ordertime0 AND EXISTS (SELECT 1 FROM vehicledisposition d JOIN couriervehicle v ON v.crvh_id=d.crvh_id WHERE d.jb_id=j.jb_id AND v.cr_id=?)))"+visible+" ORDER BY j.jb_id LIMIT 5001",jobArgs.toArray()); + if(jobs.size()>5000)throw bad("Mehr als 5000 Aufträge auf dieser Fahrzeugseite. Bitte die Zuordnungen klären."); + var known=new HashSet<>(jobs.stream().map(j->id(j,"jb_id")).toList()); + for(var vehicle:shown){ + long id=id(vehicle,"id");var a=availability.stream().filter(r->id(r,"crvh_id")==id).toList();var cells=slots.stream().filter(r->id(r,"crvh_id")==id).toList(); + if(a.size()!=1||(id(a.getFirst(),"vha_blocked")&2)!=2)throw bad("Die Tagesfreigabe ist nicht eindeutig. Bitte die Belegung neu laden."); + 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 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"))){ + int start=ordered.getHour()*units+ordered.getMinute()*units/60;int end=(int)Math.min(24L*units,start+id(j,"jb_timeunits")); + unreserved.add(VehicleDisposition.segment(start,end,job,units,known));visibleIds.add(job); + } + } + row.putAll(fields("weight",a.size()==1?decimal(daily,"vha_weight"):null,"positions",a.size()==1?id(daily,"vha_position"):null,"blocked",id(daily,"vha_blocked"),"locked",id(daily,"vha_lock")!=0,"remark",text(daily,"vha_remark"),"warning",warning,"segments",segments,"unreserved",unreserved,"jobs",jobs.stream().filter(j->visibleIds.contains(id(j,"jb_id"))).map(j->fields("id",id(j,"jb_id"),"status",id(j,"jb_status"),"offer",id(j,"jb_offer")!=0)).toList()));result.add(row); + } + } + if(courier(u)!=courier)throw CourierPortal.denied(); + return fields("day",day.toString(),"today",today.toString(),"headquarters",hq,"rows",result,"total",vehicles.size(),"offset",offset,"limit",25,"units",units,"defaultScale",new VehicleTimelines(db,support).defaultScale(hq),"confirmDate",new DispositionTimelineOptions(db).confirmDate(hq),"displayRange",new DispositionTimelineRange(db).read(hq,new VehicleDispositionCards(db,support).courierCustomer(u,courier))); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionHandover.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionHandover.java new file mode 100644 index 0000000..fd544ec --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionHandover.java @@ -0,0 +1,59 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** PHP single/rollcard handover under the courier's existing released-day boundary. */ +@RestController +@RequestMapping("/api/courier-portal/disposition/handover") +class CourierDispositionHandover { + record Command(String requestId,long vehicle,LocalDate day,long job,String action,String revision,String planRevision,boolean confirmed){} + record State(long courier,long target,String sid,String action,LocalDateTime now,List> jobs,List blockers,List warnings,String revision){} + final DatabaseService db;final BusinessSupport support;final CourierDisposition board; + CourierDispositionHandover(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);} + @GetMapping Map get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam(defaultValue="0") long job){return view(ApiController.user(a),vehicle,day,job);} + @PostMapping("/preview") Map preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);} + @PostMapping Map post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + void validate(LegacyUser u,Command c){board.courier(u);if(c==null||c.vehicle()<=0||c.job()<0||!Set.of("SEND","RECALL","ROLLCARD").contains(Objects.toString(c.action(),"")))throw bad("Ungültige Übergabe.");VehicleDisposition.date(c.day());BusinessSupport.token(c.requestId());if((c.job()==0)!=c.action().equals("ROLLCARD"))throw bad("Auftragsauswahl und Aktion passen nicht zusammen.");} + @SuppressWarnings("unchecked") State inspect(LegacyUser u,long vehicle,LocalDate day,long selected){ + long courier=board.courier(u);if(vehicle<=0||selected<0)throw bad("Ungültige Fahrzeug- oder Auftragsauswahl.");var overview=board.view(u,day,0,vehicle);var row=((List>)overview.get("rows")).getFirst();var visible=((List>)row.get("jobs")).stream().map(j->id(j,"id")).collect(java.util.stream.Collectors.toSet()); + var reserved=db.queryForList("SELECT DISTINCT jb_id FROM vehicledisposition WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot0 ORDER BY jb_id LIMIT 301",vehicle,day.toString(),day.plusDays(1).toString());var ids=selected>0?List.of(selected):reserved.stream().map(j->id(j,"jb_id")).toList();if(selected>0&&!visible.contains(selected))throw CourierPortal.denied(); + var blockers=new ArrayList();var warnings=new ArrayList();var evidence=new ArrayList(List.of(courier,overview,reserved));var now=new VehicleDisposition(db,support).now();evidence.add(now.toLocalDate()); + if(ids.size()>300)throw bad("Mehr als 300 reservierte Aufträge.");if(selected==0&&!visible.containsAll(ids))blockers.add("Die Rollkarte enthält nicht für Ihren Zugang freigegebene Belegungen. Eine gemeinsame Übergabe ist nicht möglich."); + var allowed=ids.stream().filter(visible::contains).toList();var jobs=allowed.isEmpty()?List.>of():db.queryForList("SELECT * FROM job WHERE jb_id IN ("+CourierPortal.placeholders(allowed.size())+") ORDER BY jb_id",allowed.toArray());evidence.add(jobs);if(jobs.size()!=allowed.size())throw VehicleDisposition.conflict(); + String action=selected==0?"ROLLCARD":id(jobs.getFirst(),"jb_status")==1?"RECALL":"SEND";boolean sending=!action.equals("RECALL");String sid="";long target=0; + if(day.isBefore(now.toLocalDate()))blockers.add("Vergangene Fahrzeugtage können nicht geändert werden.");if(Boolean.TRUE.equals(row.get("locked")))blockers.add("Der Fahrzeugtag wird gerade verarbeitet.");if((id(row,"blocked")&1)!=0)blockers.add("Der Fahrzeugtag ist gesperrt."); + var modes=db.queryForList("SELECT par_id,md_id,par_value FROM parameter WHERE par_key='GLOBAL_USE_RELATED_CUSTOMER' AND hq_id=0 AND emp_id=0 ORDER BY par_id");evidence.add(modes);boolean valid=modes.isEmpty()||modes.size()==1&&id(modes.getFirst(),"md_id")==0&&Set.of("0","1").contains(text(modes.getFirst(),"par_value"));boolean related=valid&&!modes.isEmpty()&&text(modes.getFirst(),"par_value").equals("1"); + if(sending){ + if(!valid)blockers.add("Der globale FDS-Betriebsmodus ist nicht eindeutig."); + if(selected==0&&!related)blockers.add("Die Classic-Sammelübergabe ist in dieser Konfiguration nicht verfügbar. Bitte Einzelaufträge prüfen."); + if(related){sid=text(row,"sid");var devices=db.queryForList("SELECT cr_id,hq_id,cr_sid,cr_id_parent,cr_app_blocked FROM courier WHERE cr_sid=? ORDER BY cr_id",sid);evidence.add(devices);if(devices.size()!=1||id(devices.getFirst(),"cr_id_parent")!=courier)blockers.add("Die Fahrzeugkennung hat keinen eindeutigen zugeordneten Unterfahrer.");else {target=id(devices.getFirst(),"cr_id");if(id(devices.getFirst(),"cr_app_blocked")!=0)blockers.add("Der App-Zugang des Unterfahrers ist gesperrt.");}} + } + var changed=new ArrayList>();for(var j:jobs){long job=id(j,"jb_id");if(selected==0&&id(j,"jb_offer")!=0)continue;changed.add(j); + if(sending&&id(j,"jb_status")!=9)blockers.add("Auftrag #"+job+": Nur reservierte Aufträge (Status 9) können übergeben werden. Der Auftrag hat Status "+id(j,"jb_status")+"."); + if(!sending&&id(j,"jb_status")!=1)blockers.add("Auftrag #"+job+": Nur angenommene Aufträge können zurückgeholt werden.");if(id(j,"jb_offer")!=0)blockers.add("Auftrag #"+job+": Angebote können nicht übergeben werden."); + try{new VehicleDisposition(db,support).writable(j,now,evidence);}catch(org.springframework.web.server.ResponseStatusException e){blockers.add("Auftrag #"+job+": "+e.getReason());} + if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=id(j,"hq_id")||id(j,"jb_longhaul")!=0||id(j,"jb_globaljob")!=0||id(j,"jb_autoranking")!=0)blockers.add("Auftrag #"+job+": gesonderter Vermittlungsablauf erforderlich."); + var reservations=db.queryForList("SELECT crvh_id,vhd_timeslot FROM vehicledisposition WHERE jb_id=? ORDER BY crvh_id,vhd_timeslot LIMIT 86401",job);evidence.add(reservations);if(reservations.size()>86400)blockers.add("Auftrag #"+job+": zu viele Reservierungen."); + if(sending&&reserved.stream().noneMatch(r->id(r,"jb_id")==job))blockers.add("Auftrag #"+job+": keine Reservierung auf diesem Fahrzeugtag."); + var days=new TreeSet();for(var r:reservations){if(id(r,"crvh_id")!=vehicle){blockers.add("Auftrag #"+job+": auch auf einem anderen Fahrzeug reserviert.");break;}try{var d=LocalDate.parse(text(r,"vhd_timeslot").substring(0,10));VehicleDisposition.slot(text(r,"vhd_timeslot"),d,(int)id(overview,"units"));days.add(d);}catch(RuntimeException e){blockers.add("Auftrag #"+job+": ungültige Reservierung.");}} + for(var d:days){var availability=db.queryForList("SELECT vha_blocked,vha_lock FROM vehicleavailability WHERE crvh_id=? AND vha_day=?",vehicle,d);evidence.add(availability);if(availability.size()!=1||(id(availability.getFirst(),"vha_blocked")&3)!=2||id(availability.getFirst(),"vha_lock")!=0)blockers.add("Auftrag #"+job+": Ein weiterer Reservierungstag ist gesperrt oder nicht für Sie freigegeben.");} + if(days.size()>1)warnings.add("Auftrag #"+job+": Die Änderung gilt für alle "+days.size()+" Reservierungstage."); + } + if(changed.isEmpty())blockers.add("Keine geeigneten Aufträge vorhanden.");warnings.add(sending?"Die Aufträge werden auf angenommen gesetzt. Im FDS-Modus erhält der Unterfahrer die Zuweisung; im Classic-Einzelablauf bleibt die Gerätezuordnung leer. Es liegt damit noch keine Empfangsbestätigung vor.":"Kurierzuordnung, Fahrzeugkennung und Annahmezeit werden geleert; der Auftrag wird wieder offen (Status 9). Es wird kein separater Gerätebefehl gesendet.");warnings.add("Reservierungen und Preise bleiben erhalten. Nach der geänderten Zuweisung können Aufträge aus Ihrer persönlichen Sicht verschwinden."); + if(board.courier(u)!=courier)throw CourierPortal.denied();evidence.add(List.of(action,target,sid,blockers,warnings));return new State(courier,target,sid,action,now,changed,List.copyOf(blockers),List.copyOf(warnings),revision(evidence)); + } + Map describe(State s){return fields("action",s.action(),"count",s.jobs().size(),"target",s.target(),"targetSid",s.sid(),"jobs",s.jobs().stream().map(j->fields("id",id(j,"jb_id"),"beforeStatus",id(j,"jb_status"),"afterStatus",s.action().equals("RECALL")?9:1,"beforeCourier",id(j,"cr_id"))).toList(),"blockers",s.blockers(),"warnings",s.warnings(),"revision",s.revision());} + Map view(LegacyUser u,long vehicle,LocalDate day,long job){return db.readTransaction(()->describe(inspect(u,vehicle,day,job)));} + State prepare(LegacyUser u,Command c){validate(u,c);var s=inspect(u,c.vehicle(),c.day(),c.job());if(!s.revision().equals(c.revision())||!s.action().equals(c.action()))throw VehicleDisposition.conflict();if(!s.blockers().isEmpty())throw bad(String.join(" ",s.blockers()));return s;} + Map preview(LegacyUser u,Command c){return db.readTransaction(()->{var s=prepare(u,c);var out=describe(s);out.put("planRevision",s.revision());return out;});} + Map save(LegacyUser u,Command c){validate(u,c);if(!c.confirmed())throw bad("Bitte die angezeigte Übergabe oder Rückholung bestätigen.");support.transactional("job","genericdatacontainer");return db.courierDispositionWriteTransaction(()->{board.view(u,c.day(),0,c.vehicle());var intent=List.of("COURIER_FDS_HANDOVER",c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);var s=prepare(u,c);if(!s.revision().equals(c.planRevision()))throw VehicleDisposition.conflict();boolean recall=s.action().equals("RECALL"); + for(var j:s.jobs()){long job=id(j,"jb_id");if(db.update("UPDATE job SET cr_id=?,cr_id_order=?,cr_sid=?,jb_status=?,jb_taketime=?,jb_globaljob=0,jb_autoranking=0,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND jb_status=? AND jb_offer=0",s.target(),s.target(),s.sid(),recall?9:1,recall?null:s.now(),job,recall?1:9)!=1)throw VehicleDisposition.conflict();support.audit(u,job,0,s.target(),recall?8:2,"COURIER_FDS_HANDOVER | "+s.action()+" | VEHICLE="+c.vehicle()+" | DAY="+c.day(),c.requestId());} + support.receipt(u,c.requestId(),intent,c.vehicle());return fields("completed",true);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionJob.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionJob.java new file mode 100644 index 0000000..ac9060b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionJob.java @@ -0,0 +1,59 @@ +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.BigDecimal; +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; + +/** PHP courier FDS popup: destination articles and station-two remark, under the released-day scope. */ +@RestController +@RequestMapping("/api/courier-portal/disposition/job") +class CourierDispositionJob { + record Command(String requestId,long vehicle,LocalDate day,long job,String revision,String value){} + record Snapshot(long courier,Map job,Map station,Map table,boolean editable,String revision){} + final DatabaseService db;final BusinessSupport support;final CourierDisposition board; + CourierDispositionJob(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);} + @GetMapping Map get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam long job){return view(ApiController.user(a),vehicle,day,job);} + @PostMapping("/remark") Map post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + String parameter(long hq,String key){var cfg=new OrderEntryCatalog(db);String value=cfg.parameter(hq,key);return value.isEmpty()?cfg.parameter(0,key):value;} + Map articles(long hq,long job){ + String raw=parameter(hq,"MASK_JOBDETAILS_ARTICLES");if(raw.isBlank())raw="9,8,1,10,3,7"; + String titles=parameter(hq,"MASK_JOBDETAILS_ARTICLE_HEADLINE");var names=titles.isBlank()?CustomerOrderItems.LABELS:Arrays.asList(titles.split(",",-1));var indexes=new ArrayList(); + for(String part:raw.split(",",-1)){if(!part.strip().matches("[0-9]{1,2}"))throw bad("Die Artikelspalten sind ungültig.");int i=Integer.parseInt(part.strip());if(i==99)continue;if(i>=CustomerOrderItems.KEYS.size())throw bad("Unbekannte Artikelspalte.");indexes.add(i);if(indexes.size()>30)throw bad("Zu viele Artikelspalten.");} + if(parameter(hq,"MASK_JOBDETAILS_ARTICLE_SCAN_EVENTS").equals("1")&&!indexes.contains(4))indexes.add(4); + var articles=db.queryForList("SELECT trat_id,tr_id,at_id,trat_quantity,trat_price,trat_sort,trat_packingpieces,trat_weight,trat_int_serialno,trat_serialno,trat_state,trat_remark,trat_name FROM tourarticle WHERE jb_id=? AND tr_sort=2 ORDER BY trat_sort,trat_id LIMIT 1001",job); + if(articles.size()>1000)throw bad("Mehr als 1.000 Artikel an Station 2."); + var rows=new ArrayList>();BigDecimal weight=BigDecimal.ZERO,price=BigDecimal.ZERO,pieces=BigDecimal.ZERO; + for(var article:articles){var cells=new ArrayList();for(int i:indexes){String value=text(article,CustomerOrderItems.KEYS.get(i));if(i==9&&value.codePointCount(0,value.length())>50)value=value.substring(0,value.offsetByCodePoints(0,50));cells.add(value);}rows.add(fields("id",id(article,"trat_id"),"cells",cells));weight=weight.add(BillingService.nz(decimal(article,"trat_weight")));price=price.add(BillingService.nz(decimal(article,"trat_price")));pieces=pieces.add(BillingService.nz(decimal(article,"trat_packingpieces")));} + var extra=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_addinfo'",job);if(extra.size()>1)throw bad("Die Artikel-Zusatzinformation ist mehrdeutig."); + return fields("columns",indexes.stream().map(i->fields("label",i>)scope.get("rows"); + if(vehicles.size()!=1||((List>)vehicles.getFirst().get("jobs")).stream().noneMatch(j->id(j,"id")==job))throw CourierPortal.denied(); + var j=one(db.queryForList("SELECT jb_id,hq_id,jb_status,jb_version,jb_storno,jb_offer,cr_id,cr_id_order,cr_sid,jb_export_time,jb_locktime FROM job WHERE jb_id=?",job)); + var stops=db.queryForList("SELECT tr_id,tr_comp,tr_person,tr_phone,tr_remark FROM tour WHERE jb_id=? AND tr_sort=2 ORDER BY tr_id",job); + if(stops.size()!=1)throw bad("Die zweite Station fehlt oder ist mehrdeutig."); + var invoices=db.queryForList("SELECT inv_id FROM invoice WHERE jb_id=?",job);var lock=dateTime(j,"jb_locktime");var now=new VehicleDisposition(db,support).now(); + boolean editable=Set.of(0L,1L,9L).contains(id(j,"jb_status"))&&id(j,"jb_storno")==0&&!OrderOperations.exported(j)&&invoices.isEmpty()&&(lock==null||!lock.isAfter(now.minusMinutes(5))); + var table=articles(u.headquartersId(),job); + if(board.courier(u)!=courier)throw CourierPortal.denied(); + return new Snapshot(courier,j,stops.getFirst(),table,editable,revision(List.of(vehicle,day,courier,vehicles,j,stops,table,invoices,editable))); + } + Map view(LegacyUser u,long vehicle,LocalDate day,long job){return db.readTransaction(()->{var s=snapshot(u,vehicle,day,job);var out=new LinkedHashMap<>(s.table());out.putAll(fields("vehicle",vehicle,"job",job,"day",day.toString(),"status",id(s.job(),"jb_status"),"destination",fields("company",text(s.station(),"tr_comp"),"person",text(s.station(),"tr_person"),"phone",text(s.station(),"tr_phone")),"remark",text(s.station(),"tr_remark"),"canRemark",s.editable(),"revision",s.revision()));return out;});} + Map save(LegacyUser u,Command c){ + if(c==null||c.value()==null||c.value().getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65535||c.value().indexOf('\0')>=0)throw bad("Die Stationsbemerkung ist zu lang oder enthält ungültige Zeichen.");BusinessSupport.token(c.requestId()); + board.courier(u);support.transactional("job","tour","genericdatacontainer");var intent=List.of("COURIER_FDS_STATION_REMARK",c); + return db.courierDispositionWriteTransaction(()->{var s=snapshot(u,c.vehicle(),c.day(),c.job());if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true); + if(!s.editable())throw bad("Die Stationsbemerkung dieses Auftrags ist schreibgeschützt.");if(!s.revision().equals(c.revision()))throw VehicleDisposition.conflict(); + if(!text(s.station(),"tr_remark").equals(c.value())){db.update("UPDATE tour SET tr_remark=? WHERE tr_id=? AND jb_id=? AND tr_sort=2",c.value(),id(s.station(),"tr_id"),c.job());db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",c.job());support.audit(u,c.job(),0,s.courier(),2,"COURIER_FDS_STATION_REMARK | BEFORE="+support.encode(text(s.station(),"tr_remark"))+" | AFTER="+support.encode(c.value()),c.requestId());} + support.receipt(u,c.requestId(),intent,c.job());return fields("completed",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionPlanning.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionPlanning.java new file mode 100644 index 0000000..59c359c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionPlanning.java @@ -0,0 +1,83 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Explicit time/reordering editor for the courier's released reservation day. */ +@RestController +@RequestMapping("/api/courier-portal/disposition/planning") +class CourierDispositionPlanning { + record Command(String requestId,long vehicle,LocalDate day,List entries,String revision,String planRevision,boolean confirmed){} + record State(int units,LocalDateTime now,Map vehicle,Map availability,List> jobs,long[] grid,Map customers,List evidence,List blockers,String revision){} + record Plan(State state,long[] grid,List> changes,List> blocks,List warnings,String revision){} + final DatabaseService db;final BusinessSupport support;final CourierDisposition board; + CourierDispositionPlanning(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;board=new CourierDisposition(db,support);} + @GetMapping Map get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day){return view(ApiController.user(a),vehicle,day);} + @PostMapping("/preview") Map preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);} + @PostMapping Map post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + void validate(LegacyUser u,Command c){board.courier(u);if(c==null||c.vehicle()<=0||c.entries()==null||c.entries().isEmpty()||c.entries().size()>300)throw bad("Eine vollständige Auftragsliste wird benötigt (maximal 300).");BusinessSupport.token(c.requestId());VehicleDisposition.date(c.day());} + @SuppressWarnings("unchecked") State inspect(LegacyUser u,long vehicle,LocalDate day){ + VehicleDisposition.date(day);if(vehicle<=0)throw bad("Bitte ein Fahrzeug wählen.");long courier=board.courier(u);var overview=board.view(u,day,0,vehicle);var row=((List>)overview.get("rows")).getFirst();int units=(int)id(overview,"units"); + var visible=((List>)row.get("jobs")).stream().map(j->id(j,"id")).collect(java.util.stream.Collectors.toSet()); + var cells=db.queryForList("SELECT vhd_timeslot,jb_id FROM vehicledisposition WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot(List.of(courier,overview,cells));var blockers=new ArrayList();var now=new VehicleDisposition(db,support).now();evidence.add(now.toLocalDate()); + var available=one(db.queryForList("SELECT * FROM vehicleavailability WHERE crvh_id=? AND vha_day=?",vehicle,day));evidence.add(available); + var v=one(db.queryForList("SELECT crvh_id,crvh_service FROM couriervehicle WHERE crvh_id=?",vehicle));evidence.add(v); + if(day.isBefore(now.toLocalDate()))blockers.add("Vergangene Fahrzeugtage können nicht umgeplant werden.");if((id(available,"vha_blocked")&1)!=0)blockers.add("Der Fahrzeugtag ist gesperrt.");if(id(available,"vha_lock")!=0)blockers.add("Der Fahrzeugtag wird gerade verarbeitet."); + if(cells.size()!=24*units)blockers.add("Das Tagesraster ist unvollständig.");VehicleDisposition.segments(cells,day,units,visible);long[] grid=new long[24*units];for(var r:cells)grid[VehicleDisposition.slot(text(r,"vhd_timeslot"),day,units)]=id(r,"jb_id"); + var ids=Arrays.stream(grid).filter(n->n>0).distinct().boxed().toList();if(ids.size()>300)throw bad("Mehr als 300 reservierte Aufträge.");if(ids.isEmpty())blockers.add("Keine reservierten Aufträge vorhanden.");if(!visible.containsAll(ids))blockers.add("Der Fahrzeugtag enthält nicht für Sie freigegebene Belegungen. Eine gemeinsame Umsortierung ist nicht möglich."); + var known=ids.stream().filter(visible::contains).toList();var jobs=known.isEmpty()?List.>of():db.queryForList("SELECT * FROM job WHERE jb_id IN ("+CourierPortal.placeholders(known.size())+") ORDER BY jb_id",known.toArray());evidence.add(jobs);if(jobs.size()!=known.size())throw VehicleDisposition.conflict();var customers=new LinkedHashMap(); + long mandator=mandator(db,u);for(var j:jobs){long job=id(j,"jb_id");if(!Set.of(0L,9L).contains(id(j,"jb_status"))||id(j,"jb_offer")!=0)blockers.add("Auftrag #"+job+": Nur offene oder reservierte Aufträge ohne Angebot können umgeplant werden."); + try{new VehicleDisposition(db,support).writable(j,now,evidence);}catch(org.springframework.web.server.ResponseStatusException e){blockers.add(e.getReason());} + if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=id(j,"hq_id")||id(j,"jb_longhaul")!=0||id(j,"jb_globaljob")!=0||id(j,"jb_autoranking")!=0)blockers.add("Auftrag #"+job+": gesonderter Vermittlungsablauf erforderlich."); + var reservations=db.queryForList("SELECT crvh_id,vhd_timeslot FROM vehicledisposition WHERE jb_id=? ORDER BY crvh_id,vhd_timeslot LIMIT 86401",job);evidence.add(reservations);if(reservations.size()>86400||reservations.stream().anyMatch(r->id(r,"crvh_id")!=vehicle||!text(r,"vhd_timeslot").startsWith(day+"_")))blockers.add("Auftrag #"+job+": Reservierungen an weiteren Tagen oder Fahrzeugen bitte mit „Umplanen“ am ersten Reservierungstag bearbeiten."); + int first=VehiclePlanning.first(grid,job);long length=Arrays.stream(grid).filter(n->n==job).count();for(int i=first;itext(r,"par_key").equals(k)&&id(r,"hq_id")==hq).toList();if(matches.size()>1)throw bad("Mehrdeutige Mehrtagesvorgabe.");if(!found&&!matches.isEmpty()&&!text(matches.getFirst(),"par_value").isBlank()){String value=text(matches.getFirst(),"par_value");if(!value.matches("[1-9][0-9]{0,4}"))throw bad("Ungültige Mehrtagesvorgabe.");maximum=Integer.parseInt(value);found=true;}} + long duration=id(j,"jb_timeunits");if(duration<1||duration>maximum||duration>24*units)blockers.add("Auftrag #"+job+": keine gültige eintägige Dauer."); + } + if(board.courier(u)!=courier)throw CourierPortal.denied();evidence.add(blockers);return new State(units,now,row,available,jobs,grid,customers,evidence,List.copyOf(blockers),revision(evidence)); + } + Map view(LegacyUser u,long vehicle,LocalDate day){return db.readTransaction(()->{var s=inspect(u,vehicle,day);var rows=new ArrayList>();for(var j:s.jobs()){long job=id(j,"jb_id");int at=VehiclePlanning.first(s.grid(),job);var planned=dateTime(j,"jb_ordertime");String time=planned!=null&&planned.toLocalDate().equals(day)?planned.toLocalTime().toString().substring(0,5):VehicleDisposition.time(at,s.units());rows.add(fields("id",job,"status",id(j,"jb_status"),"time",time,"from",VehicleDisposition.time(at,s.units()),"duration",id(j,"jb_timeunits"),"slots",Arrays.stream(s.grid()).filter(n->n==job).count()));}rows.sort(Comparator.comparing(r->text(r,"from")));return fields("vehicle",vehicle,"sid",text(s.vehicle(),"sid"),"day",day.toString(),"step",60/s.units(),"rows",rows,"blockers",s.blockers(),"revision",s.revision());});} + Plan prepare(LegacyUser u,Command c){validate(u,c);var s=inspect(u,c.vehicle(),c.day());if(!s.revision().equals(c.revision()))throw VehicleDisposition.conflict();if(!s.blockers().isEmpty())throw bad(String.join(" ",s.blockers())); + var evidence=new ArrayList(s.evidence());var durations=new LinkedHashMap();for(var j:s.jobs())durations.put(id(j,"jb_id"),(int)id(j,"jb_timeunits"));var placements=VehiclePlanning.placements(c.entries(),durations,s.units(),true);long[] grid=new long[s.grid().length];var changes=new ArrayList>();var warnings=new ArrayList();var starts=new LinkedHashMap(); + for(var p:placements){for(int i=p.start();iid(r,"jb_id")==p.job()).findFirst().orElseThrow();String at=VehicleDisposition.time(p.start(),s.units());String supplied=c.entries().stream().filter(e->e.job()==p.job()).findFirst().orElseThrow().time();changes.add(fields("id",p.job(),"before",text(j,"jb_ordertime"),"after",c.day()+" "+at+":00","from",at,"to",VehicleDisposition.time(p.end(),s.units()),"slots",p.end()-p.start(),"duration",p.requested()));starts.put(p.job(),new VehiclePlanningDependencies.Start(c.vehicle(),c.day().atStartOfDay().plusMinutes(p.start()*60L/s.units()))); + if(!supplied.equals(at))warnings.add("Auftrag #"+p.job()+": "+supplied+" wird im Raster auf "+at+" aufgerundet.");if(p.end()-p.start()Objects.equals(dateTime(r,"before"),dateTime(r,"after"))))throw bad("Keine Änderung vorhanden."); + warnings.addAll(dependencies(u,s,starts,evidence)); + var relations=new VehicleCustomerRelations(db,support);var rows=relations.rows(c.vehicle());evidence.add(rows);var contexts=new HashMap();long mandator=mandator(db,u); + for(var r:rows){long cs=id(r,"cs_id");var customer=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)=?)",cs,mandator);evidence.add(customer);if(customer.size()!=1)throw bad("Eine Fahrzeug-Kunden-Zuordnung ist nicht eindeutig verfügbar.");var ctx=relations.context(cs,s.units());VehicleCustomerRelations.validateSchedule(VehicleAvailability.decode(text(r,"csvh_day_times")),ctx);if(!Set.of(0L,1L).contains(id(r,"csvh_relation")))throw bad("Ungültiger Zuordnungsstatus.");contexts.put(cs,ctx);evidence.add(ctx);} + for(var p:placements){long cs=s.customers().get(p.job());var relation=rows.stream().filter(r->id(r,"cs_id")==cs&&id(r,"csvh_relation")==1).findFirst().orElseThrow(()->bad("Auftrag #"+p.job()+": Kunde ist dem Fahrzeug nicht als erwünscht zugeordnet."));var inactive=new HashSet();VehicleAvailability.decode(text(relation,"csvh_day_times")).stream().filter(cell->cell.weekday()==c.day().getDayOfWeek().getValue()&&cell.zone().equals("9999")).forEach(cell->inactive.add(cell.daytime()));for(int i=p.start();i!inactive.contains(w.id())&&at>=w.start()&&at!contexts.containsKey(id(r,"cs_id"))))throw bad("Verfügbarkeitsblöcke ohne Kundenbeziehung vorhanden. Bitte im Fahrzeugstamm klären."); + boolean[] free=new boolean[grid.length];for(int i=0;i>();for(var r:rows){long cs=id(r,"cs_id");for(var b:VehicleAvailability.blocks(contexts.get(cs).windows(),VehicleAvailability.decode(text(r,"csvh_day_times")),c.day().getDayOfWeek().getValue(),id(r,"csvh_relation")==1,free,earliest))blocks.add(List.of(aid,b.daytime(),b.units(),cs,b.zone(),VehicleAvailability.slotName(b.start(),s.units())));} + warnings.add("Alle Reservierungen dieses Fahrzeugtags werden gemeinsam umsortiert. Kurierzuordnung, Auftragsdauer, Preise und Rest-Nutzlast bleiben erhalten. Es werden keine Nachrichten versendet.");evidence.add(changes);evidence.add(blocks);evidence.add(warnings);return new Plan(s,grid,changes,blocks,warnings,revision(evidence)); + } + List dependencies(LegacyUser u,State s,Map starts,List evidence){return dependencies(u,s.jobs(),s.units(),starts,evidence);} + @SuppressWarnings("unchecked") List dependencies(LegacyUser u,List> sourceJobs,int units,Map starts,List evidence){ + var links=new LinkedHashSet();var jobs=new HashMap>();var warnings=new ArrayList();for(var j:sourceJobs){long job=id(j,"jb_id");jobs.put(job,j);if(id(j,"jb_id_parent")>0)links.add(new VehiclePlanningDependencies.Link(id(j,"jb_id_parent"),job));var children=db.queryForList("SELECT jb_id,hq_id,jb_id_parent,jb_service FROM job WHERE jb_id_parent=? ORDER BY jb_id LIMIT 301",job);evidence.add(children);if(children.size()>300)throw bad("Zu viele verknüpfte Aufträge.");for(var child:children)links.add(new VehiclePlanningDependencies.Link(job,id(child,"jb_id")));if((id(j,"jb_service")&2)!=0){var vehicle=one(db.queryForList("SELECT crvh_service FROM couriervehicle WHERE crvh_id=?",starts.get(job).vehicle()));evidence.add(vehicle);if((id(vehicle,"crvh_service")&2)==0)warnings.add("Auftrag #"+job+": Montage erforderlich, Fahrzeug ist nicht als Montagefahrzeug gekennzeichnet.");}} + var helper=new VehiclePlanningDependencies(db);for(var link:links){for(long job:List.of(link.parent(),link.child()))if(!jobs.containsKey(job)){ + var start=helper.start(job,starts,units,evidence);if(start==null)throw bad("Ein verknüpfter Auftrag hat keine überprüfbare Reservierung. Bitte die Reihenfolge in der Zentrale klären."); + Map other;try{other=board.view(u,start.time().toLocalDate(),0,start.vehicle());}catch(org.springframework.security.access.AccessDeniedException e){throw bad("Ein verknüpfter Auftrag ist nicht für Ihre Disposition freigegeben. Bitte die Reihenfolge in der Zentrale klären.");} + var row=((List>)other.get("rows")).getFirst();if(((List>)row.get("jobs")).stream().noneMatch(j->id(j,"id")==job))throw bad("Ein verknüpfter Auftrag ist nicht für Ihre Disposition freigegeben. Bitte die Reihenfolge in der Zentrale klären.");evidence.add(other);var j=one(db.queryForList("SELECT jb_id,hq_id,jb_id_parent,jb_service FROM job WHERE jb_id=?",job));jobs.put(job,j);evidence.add(j); + } + if(link.parent()==link.child()||links.contains(new VehiclePlanningDependencies.Link(link.child(),link.parent())))throw bad("Die Auftragsverknüpfung enthält einen Kreis.");var parent=starts.get(link.parent());var child=starts.get(link.child()); + var params=db.queryForList("SELECT par_id,hq_id,par_value FROM parameter WHERE emp_id=0 AND par_key='MASK_DISPOSITION_CHECK_DELIVERY_BEFORE_SERVICE_DISABLED' AND hq_id IN (0,?) ORDER BY par_id",id(jobs.get(link.child()),"hq_id"));evidence.add(params);boolean warningOnly=false;for(long hq:new long[]{id(jobs.get(link.child()),"hq_id"),0}){var local=params.stream().filter(r->id(r,"hq_id")==hq).toList();if(local.size()>1)throw bad("Mehrdeutige Vorgabe zur Liefer-/Montagereihenfolge.");if(!local.isEmpty()&&!text(local.getFirst(),"par_value").isBlank()){String value=text(local.getFirst(),"par_value");if(!Set.of("0","1").contains(value))throw bad("Ungültige Vorgabe zur Liefer-/Montagereihenfolge.");warningOnly=value.equals("1");break;}} + if(!parent.time().isBefore(child.time())){String problem="Lieferung #"+link.parent()+" muss vor dem verknüpften Montage-/Folgeauftrag #"+link.child()+" beginnen.";if(!warningOnly)throw bad(problem);warnings.add(problem+" Die Niederlassungsvorgabe erlaubt diese Reihenfolge nach Bestätigung.");}if(parent.vehicle()!=child.vehicle())warnings.add("Verknüpfte Aufträge #"+link.parent()+" / #"+link.child()+" bleiben auf unterschiedlichen Fahrzeugen."); + }return warnings; + } + Map preview(LegacyUser u,Command c){return db.readTransaction(()->{var p=prepare(u,c);return fields("planRevision",p.revision(),"changes",p.changes(),"warnings",p.warnings());});} + Map save(LegacyUser u,Command c){validate(u,c);if(!c.confirmed())throw bad("Bitte die geprüfte Reihenfolge und Uhrzeiten bestätigen.");support.transactional("job","vehicledisposition","vehicleavailabilitytimeunits","genericdatacontainer");return db.courierPlanningWriteTransaction(()->{ + board.view(u,c.day(),0,c.vehicle());var intent=List.of("COURIER_FDS_PLANNING",c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true);var p=prepare(u,c);if(!p.revision().equals(c.planRevision()))throw VehicleDisposition.conflict(); + db.update("UPDATE vehicledisposition SET jb_id=0 WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot0)db.update("UPDATE vehicledisposition SET jb_id=? WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot get(Authentication a,@RequestParam long vehicle,@RequestParam LocalDate day,@RequestParam long job){return view(ApiController.user(a),vehicle,day,job);} + @PostMapping("/preview") Map preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);} + @PostMapping Map post(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + @SuppressWarnings("unchecked") Map row(LegacyUser u,long vehicle,LocalDate day){return ((List>)board.view(u,day,0,vehicle).get("rows")).getFirst();} + @SuppressWarnings("unchecked") Set visible(Map row){return ((List>)row.get("jobs")).stream().map(j->id(j,"id")).collect(java.util.stream.Collectors.toSet());} + void validate(LegacyUser u,Command c){board.courier(u);if(c==null||c.vehicle()<=0||c.target()<=0||c.job()<=0)throw bad("Fahrzeug und Auftrag fehlen.");VehicleDisposition.date(c.day());VehicleDisposition.date(c.startDay());if(!c.day().equals(c.startDay()))throw bad("Die Umplanung beginnt am ausgewählten Starttag. Bitte diesen Fahrzeugtag öffnen.");BusinessSupport.token(c.requestId());} + State state(LegacyUser u,long vehicle,LocalDate day,long job){ + VehicleDisposition.date(day);if(vehicle<=0||job<=0)throw bad("Fahrzeug und Auftrag fehlen.");long courier=board.courier(u);var source=row(u,vehicle,day);if(!visible(source).contains(job))throw CourierPortal.denied(); + var evidence=new ArrayList(List.of(courier,source));var j=one(db.queryForList("SELECT * FROM job WHERE jb_id=?",job));evidence.add(j);var v=one(db.queryForList("SELECT crvh_id,cr_id,crvh_sid,crvh_vh_sign FROM couriervehicle WHERE crvh_id=?",vehicle));evidence.add(v); + int units=new VehicleDisposition(db,support).units();evidence.add(units);var reservations=db.queryForList("SELECT crvh_id,vhd_timeslot FROM vehicledisposition WHERE jb_id=? ORDER BY crvh_id,vhd_timeslot LIMIT 86401",job);evidence.add(reservations);var before=reservationRanges(reservations,vehicle,units); + for(var r:before){Map released;try{released=r.day().equals(day)?source:row(u,vehicle,r.day());}catch(org.springframework.security.access.AccessDeniedException e){throw bad("Nicht alle bisherigen Reservierungstage sind für Ihre Disposition freigegeben.");}if(!visible(released).contains(job))throw CourierPortal.denied();evidence.add(released);} + if(!before.getFirst().day().equals(day))throw bad("Ein mehrtägiger Auftrag kann nur an seinem ersten Reservierungstag umgeplant werden: "+before.getFirst().day()+"."); + var customers=db.queryForList("SELECT cs.cs_id,cs.hq_id FROM customer cs JOIN costcenter csc ON csc.cs_id=cs.cs_id WHERE csc.csc_id=? AND cs.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)",id(j,"csc_id_related"),mandator(db,u));if(customers.size()!=1)throw bad("Der zugeordnete Kunde ist nicht eindeutig verfügbar.");evidence.add(customers);long customer=id(customers.getFirst(),"cs_id"); + String maximumValue=parameter(id(j,"hq_id"),customer,"JB_SERVICE_INSTALLAION_MAX_TIMEUNITS",evidence);if(!maximumValue.isBlank()&&!maximumValue.matches("[1-9][0-9]{0,4}"))throw bad("Ungültige Mehrtagesvorgabe.");int maximum=maximumValue.isBlank()?48:Integer.parseInt(maximumValue); + var ctx=relations.context(customer,units);evidence.add(ctx);int following=followingStart(parameter(id(j,"hq_id"),customer,"CS_DAY_TIMEUNIT_START",evidence),units,ctx.windows()); + var articles=db.queryForList("SELECT trat_id,jb_id,trat_weight FROM tourarticle WHERE jb_id=? ORDER BY trat_id LIMIT 10001",job);evidence.add(articles);var weight=articleWeight(articles,Set.of(job));var now=new VehicleDisposition(db,support).now();evidence.add(now.toLocalDate());if(board.courier(u)!=courier)throw CourierPortal.denied();return new State(j,v,customer,units,maximum,following,before,weight,evidence,now,revision(evidence)); + } + @SuppressWarnings("unchecked") Map view(LegacyUser u,long vehicle,LocalDate day,long job){return db.readTransaction(()->{ + var s=state(u,vehicle,day,job);var targets=new ArrayList>();int offset=0;while(true){var page=board.view(u,day,offset);for(var r:(List>)page.get("rows"))targets.add(fields("id",id(r,"id"),"label",text(r,"sid"),"registration",text(r,"registration")));offset+=25;if(offset>=id(page,"total"))break;} + var at=dateTime(s.job(),"jb_ordertime");String time=at!=null&&at.toLocalDate().equals(day)?at.toLocalTime().toString().substring(0,5):VehicleDisposition.time(s.before().getFirst().start(),s.units()); + return fields("job",job,"vehicle",vehicle,"sid",text(s.vehicle(),"crvh_sid"),"today",s.now().toLocalDate().toString(),"initial",false,"startDay",day.toString(),"time",time,"durationMinutes",id(s.job(),"jb_timeunits")*60/s.units(),"dailyMinutes",s.maximum()*60/s.units(),"followingStart",VehicleDisposition.time(s.followingStart(),s.units()),"step",60/s.units(),"articleWeight",s.articleWeight(),"before",describe(s.before(),s.units()),"targets",targets,"revision",s.revision()); + });} + Calendar calendar(LegacyUser u,Key key,State s,List evidence){ + if(key.day().isBefore(s.now().toLocalDate()))throw bad("Vergangene Reservierungstage können nicht umgeplant werden.");Map scoped;try{scoped=row(u,key.vehicle(),key.day());}catch(org.springframework.security.access.AccessDeniedException e){throw bad("Nicht alle betroffenen Fahrzeugtage sind für Ihre Disposition freigegeben.");}evidence.add(scoped); + var a=one(db.queryForList("SELECT * FROM vehicleavailability WHERE crvh_id=? AND vha_day=?",key.vehicle(),key.day()));if(id(a,"vha_lock")!=0)throw bad("Ein Fahrzeugtag wird gerade verarbeitet.");if((id(a,"vha_blocked")&1)!=0)throw bad("Ein betroffener Fahrzeugtag ist gesperrt."); + var slots=db.queryForList("SELECT vhd_timeslot,jb_id FROM vehicledisposition WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslotid(r,"jb_id")).filter(n->n>0).toList()))throw bad("Ein Fahrzeugtag enthält nicht für Sie freigegebene Belegungen. Bitte die Planung in der Zentrale klären."); + VehicleDisposition.segments(slots,key.day(),s.units(),Set.of());if(slots.size()!=24*s.units())throw bad("Ein betroffenes Tagesraster ist unvollständig.");evidence.add(key);evidence.add(a);evidence.add(slots);long[] cells=new long[24*s.units()];for(var r:slots)cells[VehicleDisposition.slot(text(r,"vhd_timeslot"),key.day(),s.units())]=id(r,"jb_id");return new Calendar(a,cells,jobs(cells)); + } + String parameter(long headquarters,long customer,String key,List evidence){ + var rows=db.queryForList("SELECT par_id,par_key,hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN (?,?) ORDER BY par_id",headquarters,key+"_"+customer,key);evidence.add(rows); + for(String k:List.of(key+"_"+customer,key))for(long hq:new long[]{headquarters,0}){ + var matches=rows.stream().filter(r->text(r,"par_key").equals(k)&&id(r,"hq_id")==hq).toList(); + if(matches.size()>1)throw bad("Mehrdeutige Mehrtagesvorgabe: "+key); + if(!matches.isEmpty()&&!text(matches.getFirst(),"par_value").isBlank())return text(matches.getFirst(),"par_value"); + }return ""; + } + + Plan prepare(LegacyUser u,Command c){ + validate(u,c);var s=state(u,c.vehicle(),c.day(),c.job());if(!s.revision().equals(c.revision()))throw VehicleDisposition.conflict();var evidence=new ArrayList<>(s.evidence());var j=s.job(); + 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 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()){ + 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()){ + var relation=rows.stream().filter(r->id(r,"cs_id")==s.customer()&&id(r,"csvh_relation")==1).findFirst().orElseThrow(()->bad("Der Kunde ist dem Zielfahrzeug nicht als erwünscht zugeordnet."));var schedule=VehicleAvailability.decode(text(relation,"csvh_day_times")); + for(var r:after){var inactive=new HashSet();schedule.stream().filter(cell->cell.weekday()==r.day().getDayOfWeek().getValue()&&cell.zone().equals("9999")).forEach(cell->inactive.add(cell.daytime()));for(int i=r.start();i!inactive.contains(w.id())&&at>=w.start()&&atk.vehicle()==vehicle).toList()){ + var cal=calendars.get(key);long availability=id(cal.availability(),"vha_id");var old=db.queryForList("SELECT * FROM vehicleavailabilitytimeunits WHERE vha_id=? ORDER BY cs_id,vhat_daytime,vhat_starttimeunit,vhat_timeunits,vhat_srvz_no",availability);evidence.add(old);if(old.stream().anyMatch(r->!contexts.containsKey(id(r,"cs_id"))))throw bad("Verfügbarkeitsblöcke ohne Kundenbeziehung vorhanden."); + boolean[] free=new boolean[cal.cells().length];for(int i=0;i>();for(var r:rows){long customer=id(r,"cs_id");for(var b:VehicleAvailability.blocks(contexts.get(customer).windows(),VehicleAvailability.decode(text(r,"csvh_day_times")),key.day().getDayOfWeek().getValue(),id(r,"csvh_relation")==1,free,earliest))computed.add(List.of(availability,b.daytime(),b.units(),customer,b.zone(),VehicleAvailability.slotName(b.start(),s.units())));}blocks.put(key,computed); + } + } + var capacities=new VehicleMultiDayPlanning(db,support).capacities(s,calendars,evidence); + evidence.add(c.target());evidence.add(after);evidence.add(blocks.entrySet().stream().map(e->List.of(e.getKey(),e.getValue())).toList());evidence.add(capacities.entrySet().stream().map(e->List.of(e.getKey(),e.getValue())).toList());return new Plan(s,calendars,blocks,capacities,after,warnings,revision(evidence)); + } + Map preview(LegacyUser u,Command c){validate(u,c);return db.readTransaction(()->{ + var p=prepare(u,c);var s=p.state();var warnings=new ArrayList<>(List.of("Alle bisherigen Reservierungen dieses Auftrags werden durch die angezeigten Kalendertage ersetzt. Wochenenden werden nicht automatisch übersprungen.","Stellplätze, Tagesbemerkungen und Freigaben bleiben erhalten. Freie Kundenzeitfenster werden für alle betroffenen Tage neu berechnet.")); + 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."); + 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(()->{ + board.view(u,c.day(),0,c.vehicle());board.view(u,c.day(),0,c.target());var receipt=List.of("COURIER_FDS_REPLANNING",c);if(support.receipt(u,c.requestId(),receipt)!=null)return fields("completed",true); + var p=prepare(u,c);if(!p.revision().equals(c.planRevision()))throw VehicleDisposition.conflict(); + VehicleCapacityWarning.require(!VehicleCapacityWarning.messages(p.capacities()).isEmpty(),c.capacityConfirmed()); + db.update("UPDATE vehicledisposition SET jb_id=0 WHERE jb_id=?",c.job()); + for(var r:p.after()){ + int changed=db.update("UPDATE vehicledisposition SET jb_id=? WHERE crvh_id=? AND vhd_timeslot>=? AND vhd_timeslot0)db.update("UPDATE vehicleavailability SET vha_weight=? WHERE vha_id=?",capacity.after(),availability);db.update("DELETE FROM vehicleavailabilitytimeunits WHERE vha_id=?",availability);VehicleAvailability.insertMany(db,"vehicleavailabilitytimeunits",List.of("vha_id","vhat_daytime","vhat_timeunits","cs_id","vhat_srvz_no","vhat_starttimeunit"),e.getValue());} + db.update("UPDATE job SET jb_ordertime=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",c.startDay().atStartOfDay().plusMinutes(p.after().getFirst().start()*60L/p.state().units()),c.job()); + support.audit(u,c.job(),p.state().customer(),board.courier(u),124,"Transporteur-Umplanung · Fahrzeug "+c.vehicle()+" → "+c.target()+" · bisher "+p.state().before()+" · danach "+p.after()+" · Artikelgewicht "+p.state().articleWeight()+" kg · Tageskapazitäten "+p.capacities()+" · Nutzlastausnahme bestätigt: "+c.capacityConfirmed(),c.requestId());support.receipt(u,c.requestId(),receipt,c.job());return fields("completed",true); + + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java new file mode 100644 index 0000000..343f58c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcard.java @@ -0,0 +1,45 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** The standard PHP rollcard within the courier's released vehicle/day/job scope. */ +@RestController +@RequestMapping("/api/courier-portal/disposition/rollcard") +class CourierDispositionRollcard { + final DatabaseService db;final BusinessSupport support; + CourierDispositionRollcard(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @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)); + } + @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."); + var board=new CourierDisposition(db,support);long courier=board.courier(u);var scope=board.view(u,day,0,vehicle);var vehicles=(List>)scope.get("rows");if(vehicles.size()!=1)throw CourierPortal.denied();var row=vehicles.getFirst(); + var allowed=((List>)row.get("jobs")).stream().map(j->id(j,"id")).toList(); + var jobs=List.>of(); + if(!allowed.isEmpty()){ + String reserved="(j.jb_status IN (9,0) AND j.jb_offer=0 AND EXISTS (SELECT 1 FROM vehicledisposition d WHERE d.crvh_id=? AND d.jb_id=j.jb_id AND d.vhd_timeslot>=? AND d.vhd_timeslot=? AND j.jb_ordertime''))"; + var args=new ArrayList(allowed);args.add(mandator(db,u));String clause; + if(selection.equals("RESERVED")){clause=reserved;args.addAll(List.of(vehicle,day.toString(),day.plusDays(1).toString()));} + else if(selection.equals("ACCEPTED")){clause=accepted;args.addAll(List.of(day,day.plusDays(1),text(row,"sid")));} + else {clause="("+reserved+" OR "+accepted+")";args.addAll(List.of(vehicle,day.toString(),day.plusDays(1).toString(),day,day.plusDays(1),text(row,"sid")));} + jobs=db.queryForList("SELECT j.jb_id,j.jb_id_parent,j.jb_ordertime,j.jb_status,j.jb_service,j.jb_totalprice,csc.cs_id,csc.csc_name,cs.cs_eid AS customer_label FROM job j JOIN costcenter csc ON csc.csc_id=j.csc_id_related JOIN customer cs ON cs.cs_id=csc.cs_id WHERE j.jb_id IN ("+CourierPortal.placeholders(allowed.size())+") AND (j.jb_storno IS NULL OR j.jb_storno IN (0,1,3)) AND j.jb_timeunits>0 AND cs.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND "+clause+" ORDER BY CASE WHEN j.jb_status=1 THEN 1 ELSE 0 END,TIME(j.jb_ordertime),j.jb_id LIMIT 301",args.toArray()); + if(jobs.size()>300)throw bad("Mehr als 300 Aufträge auf der Rollkarte. Bitte die Auswahl eingrenzen."); + } + var cards=new VehicleRollcards(db);var warnings=new LinkedHashSet();warnings.add("Die Rollkarte enthält ausschließlich die für Ihren Zugang freigegebenen Aufträge dieses Fahrzeugs."); + String format=cards.parameter("MASK_DISPOSITION_FUNCTIONNAME_CARTAGE_NOTE",u.headquartersId());if(format.isBlank())format=cards.parameter("MASK_DISPOSITION_FUNCTIONNAME_CARTAGE_NOTE",0); + if(!format.isBlank()&&!Set.of("getCartageNote","getServiceCartageNote").contains(format))warnings.add("Für diese Niederlassung ist ein besonderes PHP-Rollkartenformat eingerichtet. Diese Vorschau verwendet die Standardrollkarte; zusätzliche Felder des Sonderformats sind nicht enthalten."); + var parents=new HashSet<>(allowed);var v=fields("crvh_id",vehicle,"crvh_sid",text(row,"sid"),"crvh_vh_sign",text(row,"registration")); + var result=cards.document(u,vehicle,day,selection,v,jobs,parents,warnings); + if(board.courier(u)!=courier)throw CourierPortal.denied();return result; + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcardMail.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcardMail.java new file mode 100644 index 0000000..eb51c89 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionRollcardMail.java @@ -0,0 +1,20 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; + +/** Uses the shared durable mail lifecycle with a courier-only authorization boundary. */ +@RestController +@RequestMapping("/api/courier-portal/disposition/{vehicle}/rollcard/mail") +class CourierDispositionRollcardMail extends VehicleRollcardMail { + CourierDispositionRollcardMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport){super(db,support,transport);} + @Override void validateAccess(LegacyUser u){new CourierDisposition(db,support).courier(u);} + @Override LegacyUser scoped(LegacyUser original,Long headquarters){if(headquarters!=null)throw CourierPortal.denied();validateAccess(original);return original;} + @Override void vehicleAccess(LegacyUser u,long vehicle,LocalDate day){new CourierDisposition(db,support).view(u,day,0,vehicle);} + @Override void customerAccess(LegacyUser u,long customer){one(db.queryForList("SELECT cs_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)));} + @Override Map rollcard(LegacyUser u,long vehicle,LocalDate day,String selection){return new CourierDispositionRollcard(db,support).view(u,vehicle,day,selection);} + @Override boolean claim(LegacyUser original,java.util.function.Supplier work){return db.courierRollcardMailWriteTransaction(work);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDispositionSearch.java b/backend/src/main/java/de/stadtbote/votian/CourierDispositionSearch.java new file mode 100644 index 0000000..ac79a4a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDispositionSearch.java @@ -0,0 +1,50 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Exact PHP FDS identifiers, restricted to the courier's released vehicle days. */ +@RestController +@RequestMapping("/api/courier-portal/disposition/job-search") +class CourierDispositionSearch { + final DatabaseService db;final CourierDisposition board; + CourierDispositionSearch(DatabaseService db,BusinessSupport support){this.db=db;board=new CourierDisposition(db,support);} + @GetMapping Map get(Authentication a,@RequestParam String q,@RequestParam(defaultValue="0") int offset){return search(ApiController.user(a),q,offset);} + @SuppressWarnings("unchecked") Map search(LegacyUser u,String input,int offset){return db.readTransaction(()->{ + long courier=board.courier(u),mandator=mandator(db,u); + if(input==null||input.isBlank()||input.length()>100||input.indexOf('\0')>=0||offset<0||offset>100000||offset%50!=0)throw bad("Bitte eine Auftragsnummer oder Suchkennung mit höchstens 100 Zeichen eingeben."); + String q=input.trim();var modes=db.queryForList("SELECT md_id,par_value FROM parameter WHERE par_key='GLOBAL_USE_RELATED_CUSTOMER' AND hq_id=0 AND emp_id=0"); + if(modes.size()>1||!modes.isEmpty()&&(id(modes.getFirst(),"md_id")!=0||!Set.of("0","1").contains(text(modes.getFirst(),"par_value"))))throw bad("Der globale FDS-Betriebsmodus ist nicht eindeutig konfiguriert."); + boolean related=!modes.isEmpty()&&text(modes.getFirst(),"par_value").equals("1"); + String suffix=q.endsWith("/1")?"1":q.endsWith("/2")?"2":"",number=related&&!suffix.isEmpty()?q.substring(0,q.length()-2):q; + long job=0;try{if(number.matches("[0-9]{1,10}"))job=Long.parseLong(number);}catch(NumberFormatException ignored){} + var match=new ArrayList();var args=new ArrayList(); + args.add(courier);args.add(courier);args.add(mandator);args.add(mandator);args.add(courier);args.add(courier); + if(job>0){ + if(!related||!suffix.equals("2")){match.add("j.jb_id=?");args.add(job);} + if(related&&!suffix.equals("1")){match.add("(j.jb_id_parent=? AND EXISTS (SELECT 1 FROM job parent WHERE parent.jb_id=j.jb_id_parent AND parent.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?)))");args.add(job);args.add(mandator);} + } + if(related||job==0){ + match.add("EXISTS (SELECT 1 FROM tour t WHERE t.jb_id=j.jb_id AND t.tr_commission_no=?)");args.add(q); + match.add("EXISTS (SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND g.gdc_gen_fieldname IN ('jb_search','info_0') AND g.gdc_content=?)");args.add(q); + } + if(match.isEmpty())return fields("q",q,"related",related,"rows",List.of(),"offset",offset,"hasMore",false); + String hidden=related?"":" AND EXISTS (SELECT 1 FROM genericdatacontainer h WHERE h.gdc_obj_type='jb' AND h.gdc_obj_id=j.jb_id AND h.gdc_gen_fieldname='hide' AND h.gdc_content='1')"; + args.add(offset); + var found=db.queryForList("SELECT DISTINCT j.jb_id AS id,j.jb_status AS status,DATE_FORMAT(j.jb_ordertime,'%Y-%m-%d %H:%i') AS ordertime,v.crvh_id AS vehicle,v.crvh_sid AS sid,DATE_FORMAT(a.vha_day,'%Y-%m-%d') AS day FROM courier driver JOIN couriervehicle v ON v.crvh_sid=driver.cr_sid JOIN vehicleavailability a ON a.crvh_id=v.crvh_id JOIN job j ON (EXISTS (SELECT 1 FROM vehicledisposition d WHERE d.jb_id=j.jb_id AND d.crvh_id=v.crvh_id AND d.vhd_timeslot>=CAST(a.vha_day AS CHAR) AND d.vhd_timeslot'' AND COALESCE(v.crvh_modify_status,0)<>3 AND driver.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND j.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND (a.vha_blocked & 2)=2 AND a.vha_day BETWEEN '2000-01-01' AND '2100-12-31' AND j.jb_timeunits>0 AND COALESCE(j.jb_storno,0) IN (0,1,3) AND (j.cr_id=? OR (j.jb_status=0 AND COALESCE(j.cr_id_order,0)<>0 AND EXISTS (SELECT 1 FROM vehicledisposition d JOIN couriervehicle owned ON owned.crvh_id=d.crvh_id WHERE d.jb_id=j.jb_id AND owned.cr_id=?))) AND ("+String.join(" OR ",match)+")"+hidden+" ORDER BY day,sid,vehicle,id LIMIT 51 OFFSET ?",args.toArray()); + var rows=new ArrayList>();var cache=new HashMap>(); + for(var row:found.stream().limit(50).toList()){ + long vehicle=id(row,"vehicle");LocalDate day=LocalDate.parse(text(row,"day"));String key=vehicle+"/"+day; + var scoped=cache.computeIfAbsent(key,k->((List>)board.view(u,day,0,vehicle).get("rows")).getFirst()); + if(((List>)scoped.get("jobs")).stream().anyMatch(j->id(j,"id")==id(row,"id")))rows.add(row); + } + if(board.courier(u)!=courier)throw CourierPortal.denied(); + return fields("q",q,"related",related,"rows",rows,"offset",offset,"hasMore",found.size()>50); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierDriverProfile.java b/backend/src/main/java/de/stadtbote/votian/CourierDriverProfile.java new file mode 100644 index 0000000..4bc057b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierDriverProfile.java @@ -0,0 +1,59 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Driver controls from the current admin/courier_special.php. */ +@RestController +@RequestMapping("/api/master-data/couriers/{courier}/driver-profile") +class CourierDriverProfile { + final DatabaseService db;final BusinessSupport support;final MasterData masters; + CourierDriverProfile(DatabaseService db,BusinessSupport support,MasterData masters){this.db=db;this.support=support;this.masters=masters;} + record Input(String nationality,LocalDate birthdate,Long parent,Boolean appBlocked){} + record Save(String requestId,String revision,Input values){} + record Logout(String requestId,String revision,boolean confirmed){} + record Snapshot(Map courier,Map person,List> countries,List> settings,List> branches){} + @GetMapping Object get(Authentication a,@PathVariable long courier){return view(ApiController.user(a),courier);} + @GetMapping("/parents") Object parents(Authentication a,@PathVariable long courier,@RequestParam(defaultValue="")String q,@RequestParam(defaultValue="0")long headquarters){return parents(ApiController.user(a),courier,q,headquarters);} + @PutMapping Object save(Authentication a,@PathVariable long courier,@RequestBody Save r){return save(ApiController.user(a),courier,r);} + @PostMapping("/logout") Object logout(Authentication a,@PathVariable long courier,@RequestBody Logout r){return logout(ApiController.user(a),courier,r);} + Snapshot state(LegacyUser u,long courier,boolean lock){ + masters.access(u,"couriers");if(courier<=0)throw bad("Bitte zuerst den Transporteur anlegen."); + var raw=masters.load(u,"couriers",courier,lock);var cr=new LinkedHashMap();for(String key:List.of("cr_id","hq_id","usr_id","cr_eid","cr_id_parent","cr_filter","cr_app_blocked","cmp_comp"))cr.put(key,raw.get(key)); + String sql="SELECT usr_id,hq_id,usr_type,usr_country,usr_birthdate,usr_modify FROM user WHERE usr_id=?"; + var person=one(lock?db.lockRows(sql,id(cr,"usr_id")):db.queryForList(sql,id(cr,"usr_id"))); + if(id(person,"hq_id")!=u.headquartersId()||id(person,"usr_type")!=3||db.queryForList("SELECT cr_id FROM courier WHERE usr_id=?",id(cr,"usr_id")).size()!=1||!db.queryForList("SELECT emp_id FROM employee WHERE usr_id=?",id(cr,"usr_id")).isEmpty())throw bad("Das Fahrer-Benutzerkonto ist nicht eindeutig diesem Transporteur zugeordnet."); + var settings=db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key IN ('MASK_COUNTRY_GROUP','MASK_COUNTRY_DEFAULT') ORDER BY par_key",u.headquartersId());for(var r:settings)if(settings.stream().filter(x->text(x,"par_key").equals(text(r,"par_key"))).count()!=1)throw bad("Die Ländervorgaben sind mehrfach vorhanden."); + String group=settings.stream().filter(x->text(x,"par_key").equals("MASK_COUNTRY_GROUP")).map(x->text(x,"par_value")).findFirst().orElse("1");if(group.isBlank())group="1";if(!group.matches("[0-9]{1,9}"))throw bad("Die Ländergruppe ist ungültig."); + var countries=db.queryForList("SELECT cou_iso_2 AS code,cou_name AS label FROM phoenix_special.country WHERE cou_nation=? ORDER BY cou_name,cou_iso_2",Integer.parseInt(group)); + if(countries.size()>500||countries.stream().anyMatch(c->!text(c,"code").matches("[A-Z]{2}"))||countries.stream().map(c->text(c,"code")).distinct().count()!=countries.size())throw bad("Die Nationalitätenliste ist ungültig."); + return new Snapshot(cr,person,countries,settings,MasterDirectoryScope.access(db,u,1).branches()); + } + static Input values(Snapshot s){String date=text(s.person(),"usr_birthdate");return new Input(text(s.person(),"usr_country"),date.isBlank()||date.startsWith("0000-")?null:LocalDate.parse(date.substring(0,10)),id(s.courier(),"cr_id_parent"),id(s.courier(),"cr_app_blocked")==1);} + String parentLabel(Snapshot s,long parent){if(parent<=0)return "Kein Unternehmer zugeordnet";var branches=s.branches().stream().map(r->id(r,"id")).toList();var args=new ArrayList();args.add(parent);args.addAll(branches);var rows=db.queryForList("SELECT cr.cr_id,cr.cr_eid,cmp.cmp_comp,h.hq_mnemonic FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN headquarters h ON h.hq_id=cr.hq_id WHERE cr.cr_id=? AND cr.hq_id IN ("+CourierPortal.placeholders(branches.size())+")",args.toArray());return rows.size()==1?label(rows.getFirst())+" · "+text(rows.getFirst(),"hq_mnemonic"):"Bestehende Zuordnung #"+parent+" (hier nicht verfügbar)";} + static String label(Map r){return text(r,"cr_eid")+" · "+text(r,"cmp_comp");} + Map view(LegacyUser u,long courier){return db.readTransaction(()->{var s=state(u,courier,false);var commands=db.queryForList("SELECT cmde_exec_time AS requestedAt,cmde_exec_timelimit AS validUntil FROM phoenix_pda.commandexec WHERE cr_id=? AND hq_id=? AND cmd_command='2' AND cmde_mode=1 ORDER BY cmde_id DESC LIMIT 1",courier,u.headquartersId());return fields("id",courier,"label",label(s.courier()),"values",values(s),"revision",revision(s),"countries",s.countries(),"parentLabel",parentLabel(s,id(s.courier(),"cr_id_parent")),"headquarters",s.branches(),"currentHeadquarters",u.headquartersId(),"lastLogout",commands.isEmpty()?null:fields("requestedAt",text(commands.getFirst(),"requestedAt"),"validUntil",text(commands.getFirst(),"validUntil")));});} + Map parents(LegacyUser u,long courier,String query){return parents(u,courier,query,u.headquartersId());} + Map parents(LegacyUser u,long courier,String query,long headquarters){return db.readTransaction(()->{var s=state(u,courier,false);long hq=headquarters==0?u.headquartersId():headquarters;if(s.branches().stream().noneMatch(b->id(b,"id")==hq))throw MasterDirectoryScope.denied();String q=EmployeeAdministration.optional(query,100,"Unternehmersuche");var rows=db.queryForList("SELECT cr.cr_id AS id,cr.cr_eid,cmp.cmp_comp FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE cr.hq_id=? AND cr.cr_id<>? AND (LOCATE(?,cr.cr_eid)>0 OR LOCATE(?,cmp.cmp_comp)>0) ORDER BY cmp.cmp_comp,cr.cr_eid,cr.cr_id LIMIT 51",hq,courier,q,q);String branch=text(s.branches().stream().filter(b->id(b,"id")==hq).findFirst().orElseThrow(),"label");return fields("items",rows.stream().limit(50).map(r->fields("id",id(r,"id"),"label",label(r)+" · "+branch)).toList(),"truncated",rows.size()>50);});} + void parent(Snapshot s,long courier,long parent){var allowed=s.branches().stream().map(b->id(b,"id")).toList();var seen=new HashSet();seen.add(courier);long current=parent;for(int level=0;current>0;level++){if(level>=64||!seen.add(current))throw bad("Die Unternehmerzuordnung darf keinen Kreis bilden.");var rows=db.lockRows("SELECT cr_id_parent,hq_id FROM courier WHERE cr_id=?",current);if(rows.size()!=1||!allowed.contains(id(rows.getFirst(),"hq_id")))throw bad("Der Unternehmer muss zu einer freigegebenen Niederlassung gehören.");current=id(rows.getFirst(),"cr_id_parent");}} + // The first statement must be a locking read: a prior plain SELECT would establish a + // repeatable-read snapshot that misses a receipt committed while this transaction waits. + void writeLock(LegacyUser u){if(db.lockRows("SELECT member.hq_id FROM mandatorheadquarters member JOIN mandatorheadquarters origin ON origin.md_id=member.md_id WHERE origin.hq_id=? ORDER BY member.hq_id",u.originalHeadquartersId()).isEmpty())throw MasterDirectoryScope.denied();} + static String filter(String before,boolean blocked){var tokens=new ArrayList();for(String value:before.split(","))if(!value.isBlank()&&!value.equals("VA"))tokens.add(value);if(!blocked)tokens.add("VA");String result=tokens.isEmpty()?"":","+String.join(",",tokens)+",";if(result.length()>255)throw bad("Die Fahrerfilter sind zu lang.");return result;} + void logout(LegacyUser u,long courier,String requestId){var now=LocalDateTime.now().withNano(0);db.update("UPDATE courier SET cr_availabletime=?,cr_locationzipcode='LOGOUT' WHERE cr_id=? AND hq_id=?",now,courier,u.headquartersId());db.insert("`phoenix_pda`.commandexec",fields("cr_id",courier,"hq_id",u.headquartersId(),"cmd_command","2","cmde_mode",1,"cmde_param","","cmde_exec_time",now,"cmde_exec_timelimit",now.plusHours(16)));support.audit(u,0,0,courier,50,"STATUS_LOGOUT=PDA-LOGOUT DURCH WEB",requestId);} + Map save(LegacyUser u,long courier,Save r){ + masters.access(u,"couriers");if(r==null||r.values()==null)throw bad("Fahrerprofil fehlt.");BusinessSupport.token(r.requestId());var v=r.values();if(v.nationality()==null||v.parent()==null||v.parent()<0||v.appBlocked()==null||!v.nationality().matches("[A-Z]{1,10}|"))throw bad("Fahrerprofil ist ungültig.");if(v.birthdate()!=null&&(v.birthdate().isBefore(LocalDate.of(1900,1,1))||v.birthdate().isAfter(LocalDate.now())))throw bad("Bitte einen gültigen Geburtstag eingeben."); + support.transactional("mandatorheadquarters","headquarters","courier","user","genericdatacontainer");if(v.appBlocked())support.transactional("phoenix_pda.commandexec");Object intent=List.of("COURIER_DRIVER_PROFILE",courier,r); + db.writeTransaction(()->{writeLock(u);var s=state(u,courier,true);if(support.receipt(u,r.requestId(),intent)!=null)return null;unchanged(s,r.revision()); + if(!v.nationality().equals(text(s.person(),"usr_country"))&&!v.nationality().isBlank()&&s.countries().stream().noneMatch(c->text(c,"code").equals(v.nationality())))throw bad("Bitte eine Nationalität aus der aktuellen Länderliste wählen.");if(v.parent()!=id(s.courier(),"cr_id_parent"))parent(s,courier,v.parent()); + db.update("UPDATE user SET usr_country=?,usr_birthdate=? WHERE usr_id=?",v.nationality(),v.birthdate(),id(s.person(),"usr_id"));db.update("UPDATE courier SET cr_id_parent=?,cr_app_blocked=?,cr_filter=? WHERE cr_id=?",v.parent(),v.appBlocked()?1:0,filter(text(s.courier(),"cr_filter"),v.appBlocked()),courier); + if(v.appBlocked())logout(u,courier,r.requestId());support.audit(u,0,0,courier,37,"DRIVER_PROFILE="+support.encode(v),r.requestId());support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier); + } + Map logout(LegacyUser u,long courier,Logout r){masters.access(u,"couriers");if(r==null||!r.confirmed())throw bad("Bitte die Geräte-Abmeldung bestätigen.");BusinessSupport.token(r.requestId());support.transactional("mandatorheadquarters","headquarters","courier","genericdatacontainer","phoenix_pda.commandexec");Object intent=List.of("COURIER_DRIVER_LOGOUT",courier,r);db.writeTransaction(()->{writeLock(u);var s=state(u,courier,true);if(support.receipt(u,r.requestId(),intent)!=null)return null;unchanged(s,r.revision());logout(u,courier,r.requestId());support.receipt(u,r.requestId(),intent,courier);return null;});return view(u,courier);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierMaskRules.java b/backend/src/main/java/de/stadtbote/votian/CourierMaskRules.java new file mode 100644 index 0000000..3214f42 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierMaskRules.java @@ -0,0 +1,42 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import java.util.function.Function; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Visible fields and vehicle exclusions in the current courier_special.php. */ +final class CourierMaskRules { + final Set hidden; + final Set excluded; + CourierMaskRules(Function local,Function global){ + Function fallback=k->{String v=local.apply(k);return v.isEmpty()?global.apply(k):v;}; + var fields=new LinkedHashSet(); + if(fallback.apply("MASK_HIDE_CR_VHT_INV").equals("1"))fields.add("vht_id_inv"); + if(fallback.apply("MASK_HIDE_CR_OUTLAY").equals("1"))fields.add("cr_outlay"); + if(fallback.apply("MASK_HIDE_CR_DISPOSITION").equals("1"))fields.add("cr_disposition"); + if(fields.contains("cr_outlay")&&fields.contains("cr_disposition"))fields.add("cmp_no_longhaul"); + hidden=Set.copyOf(fields); + // Unlike the hide flags, PHP reads this list from the current HQ only. + String raw=local.apply("MASK_EXCLUDE_VHT_IDS").trim();var ids=new LinkedHashSet(); + if(!raw.isEmpty())for(String token:raw.split(",",-1)){ + String value=token.trim();if(!value.matches("[0-9]{1,9}")||ids.size()>=500)throw bad("Die ausgeschlossenen Fahrzeugtypen der Niederlassung sind ungültig."); + ids.add(Long.parseLong(value)); + } + excluded=Set.copyOf(ids); + } + static CourierMaskRules read(DatabaseService db,LegacyUser u){var c=new OrderEntryCatalog(db);return new CourierMaskRules(k->c.parameter(u.headquartersId(),k),k->c.parameter(0,k));} + void decorate(Map view){ + @SuppressWarnings("unchecked") var fields=(List)view.get("fields"); + view.put("fields",fields.stream().filter(f->!hidden.contains(f.key())).toList()); + @SuppressWarnings("unchecked") var options=(Map)view.get("options"); + @SuppressWarnings("unchecked") var types=(List>)options.get("vehicle"); + options.put("vehicle",types.stream().filter(t->!excluded.contains(id(t,"id"))).toList()); + } + void validate(Map old,Map values){ + for(String key:hidden)if(id(old,key)!=id(values,key))throw bad("Dieses Kurierfeld ist durch die Niederlassung ausgeblendet. Bitte den gespeicherten Stand neu laden."); + long type=id(values,"vht_id_inv"); + if(type!=id(old,"vht_id_inv")&&excluded.contains(type))throw bad("Dieser Abrechnungsfahrzeugtyp ist in der Niederlassung nicht zur Auswahl freigegeben."); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierPortal.java b/backend/src/main/java/de/stadtbote/votian/CourierPortal.java new file mode 100644 index 0000000..4bed807 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierPortal.java @@ -0,0 +1,90 @@ +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 static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.BillingService.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Separate type-3 workspace based on invoice/inv_details_cr.php. */ +@RestController +@RequestMapping("/api/courier-portal") +class CourierPortal { + record Query(LocalDate from,LocalDate to,int status,String vehicle,boolean invoiceText){} + record Remark(String requestId,long job,String revision,String value){} + record Password(String requestId,String revision,String currentPassword,String password,String confirmation){@Override public String toString(){return "CourierPassword[redacted]";}} + record Scope(Map account,long mandator,List couriers,List> vehicles,boolean running,boolean invoiceText,boolean ownInvoiceText,boolean commissionColumns,boolean remarks,boolean parent,CourierStatementPricing.Policy pricing,BigDecimal perJob){} + final DatabaseService db;final BusinessSupport support; + CourierPortal(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping Object get(Authentication a){return options(ApiController.user(a));} + @PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @PostMapping("/remark") Object remark(Authentication a,@RequestBody Remark r){return remark(ApiController.user(a),r);} + @GetMapping("/account") Object account(Authentication a){return account(ApiController.user(a));} + @PostMapping("/password") Object password(Authentication a,@RequestBody Password p){return password(ApiController.user(a),p);} + + Scope scope(LegacyUser u){ + var account=CourierPortalScope.verify(db,u);long md=mandator(db,u);var catalog=new OrderEntryCatalog(db);long hq=u.originalHeadquartersId(); + boolean parent=catalog.enabled(hq,"INV_SHOW_CR_ID_PARENT"),remarks=catalog.enabled(hq,"INV_SHOW_JB_CR_REMARK"),columns=catalog.enabled(hq,"INV_JB_CR_PRICE"); + var couriers=new ArrayList();couriers.add(id(account,"cr_id"));if(parent)for(var row:db.queryForList("SELECT cr_id FROM courier WHERE cr_id_parent=? AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) ORDER BY cr_id",id(account,"cr_id"),md))couriers.add(id(row,"cr_id")); + if(couriers.size()>5000||new HashSet<>(couriers).size()!=couriers.size())throw bad("Die Transporteurzuordnung ist nicht eindeutig oder zu groß."); + var vehicles=db.queryForList("SELECT crvh_sid AS sid,cr_id,crvh_prov FROM couriervehicle WHERE cr_id IN ("+placeholders(couriers.size())+") ORDER BY crvh_sid",couriers.toArray()); + if(vehicles.stream().map(r->text(r,"sid")).distinct().count()!=vehicles.size())throw bad("Die Fahrzeugnummern sind nicht eindeutig."); + boolean stored=catalog.enabled(hq,"MASK_CR_PRICE_MODE");LocalDateTime from=null;String value=catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE");if(stored&&!value.isBlank())try{from=CourierPriceModeDate.parse(value);}catch(RuntimeException e){throw bad("MASK_CR_PRICE_MODE_DATE ist ungültig.");} + var branch=one(db.queryForList("SELECT hq_cr_prov,hq_prov FROM headquarters WHERE hq_id=?",hq));BigDecimal perJob=nz(decimal(branch,"hq_cr_prov")).add(nz(decimal(branch,"hq_prov"))); + return new Scope(account,md,couriers,vehicles,remarks,catalog.enabled(hq,"INV_SHOW_INVOICE_TEXT"),catalog.enabled(hq,"INV_JB_INVOICE_CR"),columns,remarks&&columns,parent,new CourierStatementPricing.Policy(stored,from,columns),perJob.max(BigDecimal.ZERO)); + } + static String placeholders(int size){return String.join(",",Collections.nCopies(size,"?"));} + Map options(LegacyUser u){return db.readTransaction(()->{ + var s=scope(u);var names=db.queryForList("SELECT cmp_comp FROM company WHERE cmp_id=?",id(s.account(),"cmp_id")); + return fields("name",names.isEmpty()?u.username():text(one(names),"cmp_comp"),"canRunning",s.running(),"canInvoiceText",s.invoiceText(),"canRemarks",s.remarks(),"commissionColumns",s.commissionColumns(),"canVehicleFilter",s.parent(),"vehicles",s.parent()?s.vehicles().stream().map(v->fields("id",text(v,"sid"),"label",text(v,"sid"))).toList():List.of(),"bourse",new OrderEntryCatalog(db).enabled(u.headquartersId(),"SYSTEM_BOURSE_MODE"),"disposition",CourierDisposition.enabled(db,u,id(s.account(),"cr_id")),"archiveAvailable",false); + });} + static void check(Query q,Scope s){if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.to().isBefore(q.from())||q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.status()!=2&&(q.status()!=1||!s.running()))throw denied();if(q.vehicle()!=null&&!q.vehicle().isBlank()&&(!s.parent()||s.vehicles().stream().noneMatch(v->text(v,"sid").equals(q.vehicle()))))throw denied();if(q.invoiceText()&&!s.invoiceText())throw denied();} + static org.springframework.security.access.AccessDeniedException denied(){return new org.springframework.security.access.AccessDeniedException("Diese Transporteurdaten oder Aktion sind nicht freigegeben.");} + List> jobs(Scope s,Query q){var args=new ArrayList(s.couriers());args.add(s.mandator());args.add(q.status());args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());String date=q.status()==1?"jb_ordertime":"jb_finishtime"; + String sql="SELECT * FROM job WHERE cr_id IN ("+placeholders(s.couriers().size())+") AND hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND jb_status=? AND COALESCE(jb_offer,0)=0 AND "+date+">=? AND "+date+"2000)throw bad("Die Auswahl enthält mehr als 2000 Aufträge. Bitte den Zeitraum eingrenzen.");return rows; + } + 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()); + 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."); + var payments=id(j,"hq_id")>1000?db.queryForList("SELECT jbp_price,jbp_counter,jbp_mode FROM jobpayment WHERE jb_id=?",jid):List.>of(); + var amount=CourierStatementPricing.calculate(j,services,nz(decimal(vehicle.getFirst(),"crvh_prov")),s.pricing(),stations.size(),payments); + var types=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(j,"vht_id")); + if(types.size()>1)throw bad("Der Fahrzeugtyp ist mehrdeutig."); + var detail=db.queryForList("SELECT mt_sort,jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort IN (1,3,9)",jid); + var service=detail.stream().filter(p->id(p,"mt_sort")==3).toList();if(service.size()>1)throw bad("Die Servicevergütung ist mehrdeutig."); + BigDecimal serviceFee=service.isEmpty()?BigDecimal.ZERO:nz(decimal(service.getFirst(),"jbprc_price")); + 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(); + 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()))); + return fields("rows",result,"normal",money(normal),"commissionFree",money(free),"perJob",money(s.perJob()),"commission",fee,"settlement",money(normal.subtract(fee)),"commissionColumns",s.commissionColumns(),"from",q.from(),"to",q.to()); + });} + static String remarkRevision(Scope s,Map j){return revision(List.of(id(s.account(),"cr_id"),s.couriers(),s.remarks(),id(j,"jb_id"),id(j,"cr_id"),id(j,"hq_id"),id(j,"jb_status"),id(j,"jb_offer"),OrderOperations.exported(j),text(j,"jb_cr_remark")));} + Map remark(LegacyUser u,Remark r){if(r==null||r.job()<=0||r.value()==null||r.value().length()>255||r.value().indexOf('\0')>=0)throw bad("Die Bemerkung darf höchstens 255 Zeichen enthalten.");BusinessSupport.token(r.requestId());support.transactional("job","genericdatacontainer");Object intent=List.of("COURIER_REMARK",r); + return db.courierPortalWriteTransaction(()->{var s=scope(u);if(!s.remarks())throw denied();var j=one(db.queryForList("SELECT * FROM job WHERE jb_id=?",r.job()));if(!s.couriers().contains(id(j,"cr_id"))||!List.of(1L,2L).contains(id(j,"jb_status"))||id(j,"jb_offer")!=0||OrderOperations.exported(j)||db.queryForList("SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=? AND hq_id=?",s.mandator(),id(j,"hq_id")).size()!=1)throw denied();if(support.receipt(u,r.requestId(),intent)!=null)return fields("completed",true); + if(!remarkRevision(s,j).equals(r.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Zuordnung oder Bemerkung wurde zwischenzeitlich geändert. Bitte neu laden."); + db.update("UPDATE job SET jb_cr_remark=? WHERE jb_id=?",r.value(),r.job());support.audit(u,r.job(),0,id(j,"cr_id"),2,"COURIER_PORTAL_REMARK | BEFORE="+support.encode(text(j,"jb_cr_remark"))+" | AFTER="+support.encode(r.value()),r.requestId());support.receipt(u,r.requestId(),intent,r.job());return fields("completed",true); + }); + } + static String accountRevision(Map a){return revision(List.of(id(a,"usr_id"),id(a,"cr_id"),text(a,"credential_stamp")));} + Map account(LegacyUser u){return db.readTransaction(()->{var a=CourierPortalScope.verify(db,u);return fields("account",text(a,"usr_account"),"revision",accountRevision(a),"twoFactorActive",id(a,"usr_totp_activated")==1,"passwordChanged",text(a,"usr_password_modify"));});} + boolean matches(long user,String password){return password!=null&&password.length()<=1024&&!db.queryForList("SELECT usr_id FROM user WHERE usr_id=? AND (usr_password=PASSWORD(?) OR usr_password_old=OLD_PASSWORD(?))",user,password,password).isEmpty();} + Map password(LegacyUser u,Password p){if(p==null)throw bad("Passwortangaben fehlen.");BusinessSupport.token(p.requestId());EmployeeAccounts.validatePassword(p.password(),p.confirmation());support.transactional("user","genericdatacontainer");Object intent=List.of("COURIER_PASSWORD",Objects.toString(p.revision(),"")); + return db.courierPortalWriteTransaction(()->{var a=CourierPortalScope.verify(db,u);if(support.receipt(u,p.requestId(),intent)!=null){if(!matches(u.userId(),p.password()))throw bad("Der Vorgang wurde abgeschlossen; das Passwort weicht ab.");return fields("reauthenticate",true);} + if(!accountRevision(a).equals(p.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Zugangsdaten wurden geändert. Bitte neu anmelden.");if(!matches(u.userId(),p.currentPassword()))throw bad("Das aktuelle Passwort ist ungültig."); + db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_old='',usr_password_modify=NOW(),usr_totp_sessionkey=? WHERE usr_id=?",p.password(),PortalAccounts.sessionKey(),u.userId());support.audit(u,0,0,id(a,"cr_id"),84,"COURIER_PORTAL_PASSWORD_CHANGED | PREVIOUS_SESSIONS_REVOKED",p.requestId());support.receipt(u,p.requestId(),intent,u.userId());return fields("reauthenticate",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java b/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java new file mode 100644 index 0000000..633710b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierPortalHistory.java @@ -0,0 +1,32 @@ +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.*;import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** invoice/crHistoryUN.php: vehicle selection resolves the transporteur, not a central history right. */ +@RestController +class CourierPortalHistory { + record Query(LocalDate from,LocalDate to,String vehicle){} + final DatabaseService db;final BusinessSupport support;final CourierPortal portal; + CourierPortalHistory(DatabaseService db,BusinessSupport support,CourierPortal portal){this.db=db;this.support=support;this.portal=portal;} + @PostMapping("/api/courier-portal/history") Object get(Authentication a,@RequestBody Query q){return history(ApiController.user(a),q);} + Map history(LegacyUser u,Query q){if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())||q.vehicle()==null||q.vehicle().isBlank())throw bad("Bitte ein Fahrzeug und einen Zeitraum von höchstens einem Jahr auswählen."); + return db.readTransaction(()->{var scope=portal.scope(u);if(!scope.parent())throw CourierPortal.denied();var vehicle=scope.vehicles().stream().filter(v->text(v,"sid").equals(q.vehicle())).toList();if(vehicle.size()!=1)throw CourierPortal.denied();long cr=id(vehicle.getFirst(),"cr_id"); + try{var data=db.queryForList("SELECT l.log_id,l.logo_id,l.log_createtime,l.jb_id,l.cr_sid,l.logo_description,o.logo_op_shortname,u.usr_name,u.usr_firstname,u.usr_phone FROM "+support.historySchema+".log l LEFT JOIN "+support.historySchema+".logoperation o ON o.logo_id=l.logo_id LEFT JOIN user u ON u.usr_id=l.usr_id WHERE l.cr_id=? AND l.hq_id IN (SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=?) AND l.logo_id IN (3,6,7,8,11,35,37,86) AND l.log_createtime>=? AND l.log_createtime4000,"rows",data.stream().limit(4000).map(r->fields("id",id(r,"log_id"),"at",dateTime(r,"log_createtime"),"job",id(r,"jb_id"),"vehicle",text(r,"cr_sid"),"action",text(r,"logo_op_shortname").isBlank()?"Vorgang "+id(r,"logo_id"):text(r,"logo_op_shortname"),"name",text(r,"usr_name"),"firstName",text(r,"usr_firstname"),"phone",text(r,"usr_phone"),"detail",details((int)id(r,"logo_id"),text(r,"logo_description")))).toList()); + }catch(org.springframework.dao.DataAccessException e){throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"Die Transporteurhistorie ist derzeit nicht erreichbar.");} + }); + } + static String details(int operation,String raw){ + if(Set.of(35,37,86).contains(operation))return MasterHistory.details(MasterListColumns.Kind.couriers,operation,raw); + 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));} + 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 new file mode 100644 index 0000000..ff8acc9 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierPortalScope.java @@ -0,0 +1,23 @@ +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 { + static final String ACCOUNT_SQL="SELECT u.usr_id,u.hq_id,u.usr_account,u.usr_name,u.usr_firstname,u.usr_password_modify,u.usr_totp_activated,u.usr_totp_secret,cr.cr_id,cr.cmp_id,"+LegacyCredentialState.EXPRESSION+" AS credential_stamp FROM user u JOIN courier cr ON cr.usr_id=u.usr_id AND cr.hq_id=u.hq_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_id=? AND ?=0 AND u.hq_id=? AND u.usr_type=3 AND TRIM(u.usr_account)<>'' AND COALESCE(h.hq_disabled,0)=0 AND (SELECT COUNT(*) FROM courier allcr WHERE allcr.usr_id=u.usr_id)=1 AND NOT EXISTS (SELECT 1 FROM employee e WHERE e.usr_id=u.usr_id) AND (SELECT COUNT(*) FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id AND mh.md_id>0)=1"; + static Map verify(DatabaseService db,LegacyUser u){ + if(!u.courierAccount()||u.employeeId()!=0||u.headquartersId()!=u.originalHeadquartersId())throw MasterDirectoryScope.denied(); + var rows=db.queryForList(ACCOUNT_SQL,u.userId(),u.employeeId(),u.originalHeadquartersId());if(rows.size()!=1)throw MasterDirectoryScope.denied();return rows.getFirst(); + } + static boolean allowed(String method,String path){if(method.equals("GET")&&path.equals("/api/historical-orders"))return true;if(method.equals("POST")&&Set.of("/api/historical-orders/query","/api/historical-orders/detail").contains(path))return true; + String mail="/api/courier-portal/disposition/[1-9][0-9]*/rollcard/mail",token="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + if(method.equals("GET")&&path.matches(mail+"(/"+token+"(/page)?)?"))return true; + if(method.equals("POST")&&path.matches(mail+"/(preview|"+token+")"))return true; + + if(method.equals("GET"))return Set.of("/api/me","/api/system","/api/csrf","/api/health","/api/courier-portal","/api/courier-portal/account","/api/courier-portal/bourse","/api/courier-portal/disposition","/api/courier-portal/disposition/job","/api/courier-portal/disposition/job-search","/api/courier-portal/disposition/cards","/api/courier-portal/disposition/handover","/api/courier-portal/disposition/planning","/api/courier-portal/disposition/replanning","/api/courier-portal/disposition/rollcard","/api/courier-portal/disposition/rollcard/pdf").contains(path); + return method.equals("POST")&&Set.of("/api/logout","/api/courier-portal/query","/api/courier-portal/remark","/api/courier-portal/password","/api/courier-portal/history","/api/courier-portal/bourse/query","/api/courier-portal/bourse/detail","/api/courier-portal/disposition/job/remark","/api/courier-portal/disposition/handover","/api/courier-portal/disposition/planning","/api/courier-portal/disposition/handover/preview","/api/courier-portal/disposition/planning/preview","/api/courier-portal/disposition/replanning","/api/courier-portal/disposition/replanning/preview").contains(path); + } + private CourierPortalScope(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierPriceModeDate.java b/backend/src/main/java/de/stadtbote/votian/CourierPriceModeDate.java new file mode 100644 index 0000000..64fbe1a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierPriceModeDate.java @@ -0,0 +1,26 @@ +package de.stadtbote.votian; + +import java.time.*; +import java.time.format.*; +import java.util.*; + +/** Absolute PHP mode dates, including their time; callers retain their own < / <= boundary. */ +final class CourierPriceModeDate { + static final DateTimeFormatter GERMAN=DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT); + static LocalDateTime parse(String value){ + String raw=Objects.toString(value,"").strip(); + if(raw.isEmpty())return null; + for(var format:List.of(DateTimeFormatter.ISO_LOCAL_DATE,GERMAN)){ + try{return LocalDate.parse(raw,format).atStartOfDay();}catch(DateTimeParseException ignored){} + var timestamp=new DateTimeFormatterBuilder().append(format).appendLiteral('T').append(DateTimeFormatter.ISO_LOCAL_TIME).toFormatter().withResolverStyle(ResolverStyle.STRICT); + try{return LocalDateTime.parse(raw.replace(' ','T'),timestamp);}catch(DateTimeParseException ignored){} + } + throw new DateTimeParseException("Ungültiger Stichtag des Frachtvergütungsmodus",raw,0); + } + static boolean active(String value,LocalDateTime ordered,boolean inclusive){return active(parse(value),ordered,inclusive);} + static boolean active(LocalDateTime start,LocalDateTime ordered,boolean inclusive){ + if(ordered==null)return false; + return start==null||ordered.isAfter(start)||inclusive&&ordered.equals(start); + } + private CourierPriceModeDate(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CourierStatementPricing.java b/backend/src/main/java/de/stadtbote/votian/CourierStatementPricing.java new file mode 100644 index 0000000..84530fd --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CourierStatementPricing.java @@ -0,0 +1,37 @@ +package de.stadtbote.votian; + +import java.math.*; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.BillingService.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Current PHP services_func.inc.php::getJobCosts, courier view only. */ +final class CourierStatementPricing { + record Policy(boolean storedMode,LocalDateTime storedFrom,boolean commissionColumns){} + record Amount(List> lines,BigDecimal total,boolean commissionFree){} + static Map line(String label,BigDecimal amount){return fields("label",label,"amount",money(amount));} + static Amount calculate(Map job,List> services,BigDecimal commission,Policy policy,int stationCount,List> payments){ + long hq=id(job,"hq_id");BigDecimal stored=nz(decimal(job,hq>100&&hq<=1000?"jb_cr_subprice":"jb_cr_price")); + if(hq>1000){ + BigDecimal tax=new BigDecimal("1.19"),freight=money(stored.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2)); + BigDecimal counter=BigDecimal.ZERO,cash=BigDecimal.ZERO,ec=BigDecimal.ZERO; + for(var p:payments){BigDecimal amount=nz(decimal(p,"jbp_price"));if(id(p,"jbp_counter")==1)counter=counter.add(amount);else if(id(p,"jbp_mode")==0)cash=cash.add(amount);else if(id(p,"jbp_mode")==1)ec=ec.add(amount);} + BigDecimal due=money(nz(decimal(job,"jb_totalprice")).multiply(tax)).subtract(money(counter.multiply(tax))),cashGross=money(cash.multiply(tax)),ecGross=money(ec.multiply(tax)),open=due.subtract(cashGross).subtract(ecGross); + return new Amount(List.of(line("Fuhrlohn",freight.multiply(tax)),line("Soll",due),line("EC",ecGross),line("Bar",cashGross)),money(open),false); + } + LocalDateTime ordered=dateTime(job,"jb_ordertime");boolean after=policy.storedMode()&&CourierPriceModeDate.active(policy.storedFrom(),ordered,false); + if(stored.signum()!=0&&(hq>100||!policy.storedMode())||after)return new Amount(List.of(line("Fuhrlohn Fixpreis",stored)),money(stored),true); + var lines=new ArrayList>();BigDecimal sum=BigDecimal.ZERO;long previous=-1; + for(var p:services){if(text(p,"trs_srvt_name").equals("z")&&nz(decimal(p,"trs_price")).signum()==0)continue; + boolean split=id(job,"csc_id_payer")==0&&stationCount>1;long sort=id(p,"tr_sort");if(split&&previous==sort)continue; + BigDecimal amount=nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2); + if(policy.commissionColumns())amount=money(amount.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2)); + sum=sum.add(amount);previous=sort;String label=(text(p,"trs_srv_name").replace("
"," ")+" "+text(p,"trs_srvt_name")).strip(); + if(split)label+=" (anteilig 1/"+stationCount+")";lines.add(line(label,amount)); + } + return new Amount(lines,money(sum),false); + } + private CourierStatementPricing(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CrmController.java b/backend/src/main/java/de/stadtbote/votian/CrmController.java index 2ce9593..cd0dc1b 100644 --- a/backend/src/main/java/de/stadtbote/votian/CrmController.java +++ b/backend/src/main/java/de/stadtbote/votian/CrmController.java @@ -10,6 +10,7 @@ class CrmController { CrmController(ContactService contacts,SalesAnalytics sales){this.contacts=contacts;this.sales=sales;} @GetMapping("/options") Map options(Authentication a){return sales.options(user(a));} @GetMapping("/contacts/schema") Map schema(Authentication a,@RequestParam long headquarters){return contacts.schema(user(a),headquarters);} + @GetMapping("/contacts/draft") Map draft(Authentication a,@RequestParam long headquarters,@RequestParam(required=false) Long customerId){return contacts.draft(user(a),headquarters,customerId);} @PostMapping("/contacts/query") List> contacts(Authentication a,@RequestBody ContactService.Query q){return contacts.list(user(a),q);} @GetMapping("/contacts/{id}") Map contact(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.get(user(a),headquarters,id);} @GetMapping("/contacts/{id}/history") List> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.history(user(a),headquarters,id);} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerAddressPropagation.java b/backend/src/main/java/de/stadtbote/votian/CustomerAddressPropagation.java new file mode 100644 index 0000000..f415b70 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerAddressPropagation.java @@ -0,0 +1,82 @@ +package de.stadtbote.votian; + +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; + +/** customer_special.php: independently copy company names and postal address to existing costcenter addresses. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/address-propagation") +class CustomerAddressPropagation { + final DatabaseService db;final BusinessSupport support;final MasterData master; + CustomerAddressPropagation(DatabaseService db,BusinessSupport support,MasterData master){this.db=db;this.support=support;this.master=master;} + enum Scope { NONE, ROOT, ALL } + record Selection(Scope scope,List types){} + record Draft(String revision,Selection company,Selection address){} + record Command(String requestId,Draft draft,String fingerprint){} + record View(long customer,String label,String revision,Map source,int centers){} + record Change(long center,String label,int type,Map before,Map after,boolean company,boolean address){} + record Preview(String fingerprint,List changes){} + record State(View view,long root,List> addresses){} + static final List COMPANY=List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"); + static final List ADDRESS=List.of("ad_street","cscad_hsno","ad_zipcode","ad_city","ad_country"); + @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 d){return preview(ApiController.user(a),customer,d);} + @PostMapping Map save(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + State state(LegacyUser u,long customer,boolean lock){ + if(customer<=0)throw bad("Ungültiger Kunde."); + var owner=master.load(u,"customers",customer,lock); + String cs="SELECT csc_id,csc_name,csc_path FROM costcenter WHERE cs_id=? ORDER BY csc_id"; + var centers=lock?db.lockRows(cs,customer):db.queryForList(cs,customer); + long root=id(owner,"csc_id");if(centers.stream().noneMatch(c->id(c,"csc_id")==root))throw bad("Die Hauptkostenstelle gehört nicht zu diesem Kunden."); + String sql="SELECT ca.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country,c.csc_name,c.csc_path FROM costcenter c JOIN costcenteraddress ca ON ca.csc_id=c.csc_id LEFT JOIN address a ON a.ad_id=ca.ad_id WHERE c.cs_id=? ORDER BY c.csc_id,ca.adt_id"; + var rows=lock?db.lockRows(sql,customer):db.queryForList(sql,customer); + var seen=new HashSet();for(var row:rows)if(!seen.add(id(row,"csc_id")+":"+id(row,"adt_id")))throw bad("Mehrdeutige Kostenstellenanschriften. Bitte doppelte Einträge klären lassen."); + var source=new LinkedHashMap();source.put("ad_id",id(owner,"ad_id")); + for(String k:COMPANY)source.put(k,text(owner,k.replace("cscad_","cmp_"))); + for(String k:ADDRESS)source.put(k,text(owner,k.equals("cscad_hsno")?"cmp_hsno":k)); + return new State(new View(customer,text(owner,"cs_eid")+" · "+text(owner,"cmp_comp"),revision(List.of(customer,root,source,centers,rows)),source,centers.size()),root,rows); + } + View view(LegacyUser u,long customer){u.require(0);return db.readTransaction(()->state(u,customer,false).view());} + static Selection selection(Selection s){ + if(s==null||s.scope()==null||s.types()==null)throw bad("Bitte Geltungsbereich und Adressarten wählen."); + if(s.types().stream().anyMatch(t->t==null||!Set.of(1,2,4).contains(t))||new HashSet<>(s.types()).size()!=s.types().size())throw bad("Ungültige Adressart."); + if(s.scope()==Scope.NONE&&!s.types().isEmpty()||s.scope()!=Scope.NONE&&s.types().isEmpty())throw bad("Bitte mindestens eine Adressart für die gewählte Übernahme wählen."); + return new Selection(s.scope(),s.types().stream().sorted().toList()); + } + static Draft validate(Draft d){if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Bestandsrevision fehlt.");var result=new Draft(d.revision(),selection(d.company()),selection(d.address()));if(result.company().scope()==Scope.NONE&&result.address().scope()==Scope.NONE)throw bad("Bitte eine Übernahme wählen.");return result;} + static boolean includes(Selection s,long center,long root,int type){return s.types().contains(type)&&(s.scope()==Scope.ALL||s.scope()==Scope.ROOT&¢er==root);} + static Map display(Map row){var out=new LinkedHashMap();for(var keys:List.of(COMPANY,ADDRESS))for(String k:keys)out.put(k,text(row,k));return out;} + Preview prepare(LegacyUser u,long customer,Draft d,State s){ + if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Kunden- oder Kostenstellendaten wurden inzwischen geändert. Bitte neu laden."); + var changes=new ArrayList();for(var row:s.addresses()){ + long center=id(row,"csc_id");int type=(int)id(row,"adt_id");boolean company=includes(d.company(),center,s.root(),type),address=includes(d.address(),center,s.root(),type);if(!company&&!address)continue; + var before=display(row);var after=new LinkedHashMap<>(before);if(company)for(String k:COMPANY)after.put(k,s.view().source().get(k));if(address)for(String k:ADDRESS)after.put(k,s.view().source().get(k)); + if(!before.equals(after))changes.add(new Change(center,text(row,"csc_name"),type,before,after,company,address)); + } + return new Preview(revision(List.of(u.headquartersId(),customer,d,changes)),List.copyOf(changes)); + } + Preview preview(LegacyUser u,long customer,Draft input){u.require(0);var d=validate(input);return db.readTransaction(()->prepare(u,customer,d,state(u,customer,false)));} + Map save(LegacyUser u,long customer,Command c){ + u.require(0);if(c==null)throw bad("Übernahme fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte zuerst die Vorschau prüfen."); + var intent=List.of("CUSTOMER_ADDRESS_PROPAGATION",customer,d,c.fingerprint()); + support.transactional("headquarters","customer","company","address","employee","user","costcenter","costcenteraddress","genericdatacontainer"); + return db.writeTransaction(()->{ + one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId())); + master.load(u,"customers",customer,true);Long receipt=support.receipt(u,c.requestId(),intent);if(receipt!=null)return fields("changed",receipt,"repeated",true); + var state=state(u,customer,true);var p=prepare(u,customer,d,state);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau passt nicht zur gewählten Übernahme."); + for(var change:p.changes()){ + var values=new LinkedHashMap();if(change.company())for(String k:COMPANY)values.put(k,change.after().get(k)); + if(change.address()){values.put("ad_id",state.view().source().get("ad_id"));values.put("cscad_hsno",change.after().get("cscad_hsno"));} + var args=new ArrayList<>(values.values());args.add(change.center());args.add(change.type()); + int affected=db.update("UPDATE costcenteraddress SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE csc_id=? AND adt_id=?",args.toArray());if(affected!=1)throw bad("Kostenstellenanschrift konnte nicht eindeutig geändert werden."); + } + if(!p.changes().isEmpty())support.audit(u,0,customer,0,36,"Kundenangaben in "+p.changes().size()+" Kostenstellenanschriften übernommen · Firma "+d.company()+" · Adresse "+d.address(),c.requestId()); + support.receipt(u,c.requestId(),intent,p.changes().size());return fields("changed",p.changes().size(),"repeated",false); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java b/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java new file mode 100644 index 0000000..a560808 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCancellation.java @@ -0,0 +1,55 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** The customer cancellation branch preserves payments, disposition and standing-order rules. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{job}/cancellation") +class CustomerCancellation { + record Command(String requestId,String revision,boolean confirmed){} + record State(CustomerPortalScope.Context context,long customer,OrderOperations.Snapshot order,List> metadata,List> exports,boolean enabled,boolean related){} + final DatabaseService db;final BusinessSupport support;final OrderOperations orders; + CustomerCancellation(DatabaseService db,BusinessSupport support,OrderOperations orders){this.db=db;this.support=support;this.orders=orders;} + @GetMapping Object options(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);} + @PostMapping Object save(Authentication a,@PathVariable long job,@RequestBody Command command){return save(ApiController.user(a),job,command);} + 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); + 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); + } + String blocked(State s){ + var j=s.order().job();long job=id(j,"jb_id");if(!s.enabled())return "Die Kundenstornierung ist für diesen Kunden nicht freigegeben."; + if(!Set.of(8L,9L).contains(id(j,"jb_status"))||id(j,"jb_storno")!=0)return "Nur noch nicht angenommene Kundenaufträge mit Status 8 oder 9 können hier storniert werden."; + if(id(j,"csc_id")<=0&&db.queryForList("SELECT usr_id FROM user WHERE usr_id=? AND usr_type=2",id(j,"emp_id")).isEmpty())return "Dieser Auftrag wurde nicht über einen Kundenzugang erfasst."; + if(OrderOperations.exported(j)||!s.order().invoices().isEmpty()||s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))return "Der Auftrag ist bereits exportiert oder einer Rechnung zugeordnet."; + if(!s.related()&&id(j,"jb_id_parent")>0)return "Unteraufträge werden über den zugehörigen Hauptauftrag bearbeitet."; + if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())return "Für den Auftrag besteht bereits eine Gegenbuchung."; + var lock=dateTime(j,"jb_locktime");if(lock!=null&&lock.isAfter(LocalDateTime.now().minusMinutes(5)))return "Der Auftrag wird gerade in der alten Anwendung bearbeitet."; + return ""; + } + Map options(LegacyUser u,long job){return db.readTransaction(()->{var s=state(u,job);String reason=blocked(s);return fields("id",job,"revision",revision(s),"canCancel",reason.isEmpty(),"reason",reason,"createCredit",!s.related(),"amount",BillingService.money(decimal(s.order().job(),"jb_totalprice")),"series",id(s.order().job(),"jb_permanent")>0);});} + Map save(LegacyUser u,long job,Command c){ + if(c==null||!c.confirmed())throw bad("Bitte die kostenfreie Stornierung bestätigen.");BusinessSupport.token(c.requestId());support.transactional("job","tour","tourservice","jobprice","genericdatacontainer");var exports=new BatchInvoiceExports(db,support);exports.requireTransactional(); + return db.customerCancellationWriteTransaction(exports.schema(),()->{ + var s=state(u,job);if(!s.enabled())throw CustomerPortalScope.denied();Object intent=List.of("CUSTOMER_CANCEL",job,c.revision(),c.confirmed());Long previous=support.receipt(u,c.requestId(),intent);if(previous!=null)return fields("id",job,"credit",previous); + unchanged(s,c.revision());String reason=blocked(s);if(!reason.isEmpty())throw bad(reason);long credit=0; + if(!s.related()){ + credit=orders.credit(u,job,s.order(),new OrderOperations.Action(c.requestId(),null,"CANCEL",null,null,null,"Storno durch Kunde im Kundenzugang",false,false),"Storno durch Kunde im Kundenzugang"); + // PHP reverses the stored total exactly, even when historical service rows differ from it. + db.update("UPDATE job SET jb_totalprice=?,jb_invtext=? WHERE jb_id=?",decimal(s.order().job(),"jb_totalprice").negate(),"Storno von Auftrag Nr. "+job,credit); + for(var row:s.exports()){var item=exports.decode(text(row,"bo_obj_data"));if(item==null)continue;var reversed=new BatchInvoiceTexts.Item(item.factor(),item.description(),item.price().negate(),item.total().negate());BusinessSupport.insertWithoutKey(db,support.historySchema+".b2b_objects",fields("bo_ver",0,"bo_type",300,"bo_state",0,"bo_ext_id0",credit,"bo_ext_id1",id(s.order().job(),"hq_id"),"bo_ext_id2",job,"bo_obj_data",support.encode(reversed.legacy()),"bo_createtime",LocalDateTime.now()));} + } + var changes=fields("jb_storno",2,"usr_id_storno",u.userId(),"jb_status",2,"jb_finishtime",LocalDateTime.now(),"jb_freetext_3","Storno durch Kunde im Kundenzugang!","jb_version",id(s.order().job(),"jb_version")+1);if(credit>0)changes.put("jb_id_copy",credit);BusinessSupport.update(db,"job","jb_id",job,changes);db.update("UPDATE tour SET tr_commission_no='' WHERE jb_id=?",job); + support.audit(u,job,s.customer(),0,10,"COSTS=NO|JBP_REMOVAL=NO|FDS_REMOVAL=NO|CUSTOMER_STORNO=YES",c.requestId());support.receipt(u,c.requestId(),intent,credit);return fields("id",job,"credit",credit); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCompanySettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerCompanySettings.java new file mode 100644 index 0000000..6212e4f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCompanySettings.java @@ -0,0 +1,60 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.MasterData.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Active company controls in the current customer_special.php. */ +final class CustomerCompanySettings { + static final List FIELDS=List.of( + f("br_id","Branche","Vertrieb","branch",0,false), + f("emp_id_sales","Vertriebsmitarbeiter","Vertrieb","salesEmployee",0,false), + choice("cmp_sales_ranking_man","Subjektives Ranking","Vertrieb","0","Keine Einstufung","1","A","2","B","3","C"), + f("cmp_newsletter","Newsletter-Abonnement","Kontakt","check",1,false), + f("cmp_dsgvo","DSGVO-Zustimmung","Kontakt","check",1,false), + f("ng_single_invoice","Einzelrechnung","Abrechnung","check",1,false)); + static final Set KEYS=Set.of("br_id","emp_id_sales","cmp_sales_ranking_man","cmp_newsletter","cmp_dsgvo","ng_single_invoice"); + final DatabaseService db; + CustomerCompanySettings(DatabaseService db){this.db=db;} + static long oldValue(Map old,String key){return key.equals("ng_single_invoice")?(id(old,"cmp_inv2hq")&2)/2:id(old,key);} + static long invoiceMode(long old,long enabled){if(old<0||old>3||enabled<0||enabled>1)throw bad("Ungültige Einzelrechnungs-Vorgabe.");return (old&1)|(enabled*2);} + void decorate(LegacyUser user,Map row,Map values,Map result){ + values.put("ng_single_invoice",oldValue(row,"ng_single_invoice")); + var disabled=new ArrayList();if(!user.hasRight(24))disabled.add("emp_id_sales");if(!user.hasRight(33))disabled.add("cmp_dsgvo");result.put("disabledFields",disabled); + @SuppressWarnings("unchecked") var options=(Map)result.get("options"); + var branches=new ArrayList>();branches.add(fields("id",0,"label","Keine Branche")); + branches.addAll(db.queryForList("SELECT br_id AS id,CONCAT_WS(' · ',br_key,br_name) AS label FROM branch WHERE br_visible=1 ORDER BY br_key,br_name,br_id")); + long current=id(row,"br_id");if(current>0&&branches.stream().noneMatch(r->id(r,"id")==current))branches.add(fields("id",current,"label",storedBranch(current)+" (bisherige Zuordnung)"));options.put("branch",branches); + var employees=new ArrayList>();employees.add(fields("id",0,"label","Kein Vertriebsmitarbeiter")); + if(user.hasRight(24))employees.addAll(employees(user,false)); + long selected=id(row,"emp_id_sales");if(selected>0&&employees.stream().noneMatch(r->id(r,"id")==selected)){ + var names=db.queryForList("SELECT CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE e.emp_id=?",selected); + employees.add(fields("id",selected,"label",(names.isEmpty()?"Mitarbeiter "+selected:text(names.getFirst(),"label"))+" (bisherige Zuordnung)")); + } + options.put("salesEmployee",employees); + } + String storedBranch(long branch){var rows=db.queryForList("SELECT CONCAT_WS(' · ',br_key,br_name) AS label FROM branch WHERE br_id=?",branch);return rows.isEmpty()?"Branche "+branch:text(rows.getFirst(),"label");} + List> employees(LegacyUser user,boolean lock){ + String sql="SELECT e.emp_id AS id,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE u.usr_type=1 AND e.csc_id=0 AND u.hq_id=?"+(user.hasRight(3)?"":" AND e.emp_id=?")+" ORDER BY u.usr_name,u.usr_firstname,e.emp_id"; + Object[] args=user.hasRight(3)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.employeeId()};return lock?db.lockRows(sql,args):db.queryForList(sql,args); + } + void prepare(LegacyUser user,Map old,Map clean){ + for(String key:KEYS)clean.putIfAbsent(key,oldValue(old,key)); + if(KEYS.stream().allMatch(k->id(clean,k)==oldValue(old,k)))return; + // Re-read privileges at the write boundary, including a changed working HQ. + var current=new WorkspaceHeadquarters(db).select(user,user.headquartersId());current.require(0); + if(id(clean,"emp_id_sales")!=id(old,"emp_id_sales")){ + user.require(24);current.require(24);long employee=id(clean,"emp_id_sales"); + if(employee>0&&employees(current,true).stream().noneMatch(r->id(r,"id")==employee))throw bad("Vertriebsmitarbeiter ist in dieser Niederlassung nicht auswählbar."); + } + if(id(clean,"cmp_dsgvo")!=id(old,"cmp_dsgvo")){user.require(33);current.require(33);} + long branch=id(clean,"br_id");if(branch>0&&branch!=id(old,"br_id")&&db.lockRows("SELECT br_id FROM branch WHERE br_id=? AND br_visible=1",branch).isEmpty())throw bad("Bitte eine sichtbare Branche wählen."); + } + static void company(Map old,Map clean,Map company){ + company.put("br_id",clean.get("br_id"));company.put("emp_id_sales",clean.get("emp_id_sales")); + company.put("cmp_inv2hq",invoiceMode(id(old,"cmp_inv2hq"),id(clean,"ng_single_invoice"))); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java b/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java new file mode 100644 index 0000000..618f632 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCopy.java @@ -0,0 +1,73 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Read-only copy draft; the normal customer creation transaction owns every local write. */ +final class CustomerCopy { + record Source(long id,String revision,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) String photoRevision){Source(long id,String revision){this(id,revision,null);}} + record State(Map owner,List> parameters,boolean removePayer,String revision){} + static final List ENTITY=List.of("cs_jbedit","cs_ar","cs_joblist_disabled","cs_jbstatusmail_fields","cs_jb_jam_waittime","cs_jb_jam_waittime_minutes"); + static final String POLICY="CS_COPY_NO_THIRD_PARTY",STATION="JOBDETAILS_STATIONS_STATION_NUMBER_"; + static final List PREFIXES; + static {var p=new ArrayList<>(CustomerEntrySettings.PARAMETERS.values());p.addAll(CustomerMailSettings.PARAMETERS);p.add(CustomerEntrySettings.COLOR);PREFIXES=List.copyOf(p);} + final MasterData masters;final DatabaseService db; + CustomerCopy(MasterData masters){this.masters=masters;this.db=masters.db;} + 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;} + State state(LegacyUser u,long source,boolean lock){ + if(source<=0)throw bad("Bitte einen gespeicherten Kunden kopieren."); + var owner=masters.load(u,"customers",source,lock); + var names=new ArrayList();for(String prefix:PREFIXES)names.add(prefix+source);names.add(CustomerEntrySettings.MEASURES);names.add(POLICY); + String sql="SELECT par_id,par_key,par_value,md_id,hq_id,emp_id FROM parameter WHERE par_key IN ("+CrmScope.marks(names.size())+") ORDER BY par_key,hq_id,par_id"; + var all=lock?db.lockRows(sql,names.toArray()):db.queryForList(sql,names.toArray());var rows=new ArrayList>();var seen=new HashSet();boolean remove=false; + for(var row:all){String key=text(row,"par_key");boolean local=key.equals(POLICY)||key.equals(CustomerEntrySettings.MEASURES); + if(local&&id(row,"hq_id")!=u.headquartersId())continue; + if(id(row,"md_id")!=0||id(row,"emp_id")!=0||!local&&id(row,"hq_id")!=0||!seen.add(key))throw bad("Die Kopiervorgaben sind mehrdeutig oder haben einen abweichenden Geltungsbereich."); + if(key.equals(POLICY))remove=CustomerMailSettings.binary(text(row,"par_value"))==1; + if(key.equals(CustomerEntrySettings.MEASURES))CustomerEntrySettings.measureIds(text(row,"par_value")); + rows.add(row); + } + return new State(owner,List.copyOf(rows),remove,revision(List.of(u.userId(),u.headquartersId(),owner,rows))); + } + @SuppressWarnings("unchecked") Map draft(LegacyUser actor,long source){var draft=db.readTransaction(()->{ + var u=access(actor);var s=state(u,source,false);var original=masters.view(u,"customers",source,0);var result=masters.view(u,"customers",0,0); + var values=(Map)result.get("values");values.putAll((Map)original.get("values"));values.put("cs_eid","");values.put("cmp_archived",0);values.put("cmp_contract_date",""); + if(s.removePayer())values.put("csc_id_payer",0); + if(!u.hasRight(33))values.put("cmp_dsgvo",0);if(!u.hasRight(24))values.put("emp_id_sales",0); + 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.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; + } + 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."); + var u=access(actor);var s=state(u,source.id(),true); + if(!s.revision().equals(source.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Kopierquelle oder Kopiervorgabe wurde inzwischen geändert. Bitte die Kopie neu öffnen."); + if(s.parameters().stream().anyMatch(r->text(r,"par_key").equals(CustomerEntrySettings.PARAMETERS.get("favoriteDispatch")+source.id())&&text(r,"par_value").equals("1")))u.require(41); + return s; + } + void entity(State s,Map entity){for(String key:ENTITY)if(s.owner().containsKey(key))entity.put(key,s.owner().get(key));} + void created(LegacyUser u,long customer,State state,Map values){ + masters.support.transactional("parameter");long source=id(state.owner(),"cs_id"); + 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; + } + 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"; + if(!db.lockRows("SELECT par_id FROM parameter WHERE par_key=?",destination).isEmpty())throw bad("Für die neue Kunden-ID existieren bereits Vorgaben."); + db.insert("parameter",fields("par_key",destination,"md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text","")); + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java b/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java new file mode 100644 index 0000000..06c2676 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCostCenters.java @@ -0,0 +1,123 @@ +package de.stadtbote.votian; + +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; +import static de.stadtbote.votian.BusinessSupport.*; + +/** Customer-facing subset of customer/costcenter.php. Billing settings remain central-only. */ +@RestController +@RequestMapping("/api/customer-portal/centers") +class CustomerCostCenters { + record Command(String requestId,long customer,long center,String kind,String revision,String planRevision, + String name,long child,boolean visible,int addressType,Map address,boolean skipPostal) {} + record State(CustomerPortalScope.Context context,CustomerPortalScope.Customer customer,Map current, + List> tree,List> addresses,List> defaults, + List> parentAddresses,List> types,boolean external,boolean companyLocked) {} + record Plan(State state,Command command,Map values,List> changes) {} + record Reference(String table,String column) {} + final DatabaseService db; + final BusinessSupport support; + final OrderRoutingService routing; + final MasterAddressSuggestions suggestions; + CustomerCostCenters(DatabaseService db,BusinessSupport support,OrderRoutingService routing,MasterAddressSuggestions suggestions){this.db=db;this.support=support;this.routing=routing;this.suggestions=suggestions;} + + @GetMapping("/{customer}/{center}") Object view(Authentication a,@PathVariable long customer,@PathVariable long center){return view(ApiController.user(a),customer,center);} + @GetMapping("/{customer}/{center}/address-suggestions") Object suggest(Authentication a,@PathVariable long customer,@PathVariable long center,@RequestParam String q,@RequestParam String country,@RequestParam String sessionToken){addressAccess(ApiController.user(a),customer,center);return suggestions.suggestions(q,country,sessionToken);} + @GetMapping("/{customer}/{center}/address-details") Object details(Authentication a,@PathVariable long customer,@PathVariable long center,@RequestParam String placeId,@RequestParam String sessionToken){addressAccess(ApiController.user(a),customer,center);return suggestions.details(placeId,sessionToken);} + @PostMapping("/preview") Object preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);} + @PostMapping("/save") Object save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + + void addressAccess(LegacyUser u,long customer,long center){db.readTransaction(()->{var s=state(u,customer,center);s.context().require(3);return null;});} + State state(LegacyUser u,long customer,long center){ + var context=CustomerPortalScope.context(db,u);var cs=CustomerPortalScope.customer(context,customer); + if(!cs.allowed().contains(center))throw CustomerPortalScope.denied(); + var tree=db.queryForList("SELECT csc_id,csc_pre_id,csc_name,csc_path,csc_visible,csc_inv_interval,csc_modify FROM costcenter WHERE cs_id=? ORDER BY csc_id",customer); + var current=one(tree.stream().filter(r->id(r,"csc_id")==center).toList()); + var byId=new HashMap>();tree.forEach(r->byId.put(id(r,"csc_id"),r)); + boolean external=false;var visited=new HashSet();long cursor=center; + while(cursor>0){if(!visited.add(cursor)||!byId.containsKey(cursor))throw bad("Die Kostenstellenhierarchie ist unvollständig.");var r=byId.get(cursor);external|=text(r,"csc_name").equalsIgnoreCase("EXTERN");cursor=id(r,"csc_pre_id");} + String sql="SELECT ca.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM costcenteraddress ca LEFT JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? ORDER BY ca.adt_id"; + var addresses=db.queryForList(sql,center);var defaults=center==cs.root()?addresses:db.queryForList(sql,cs.root()); + var parentAddresses=id(current,"csc_pre_id")>0?db.queryForList(sql,id(current,"csc_pre_id")):List.>of(); + for(var list:List.of(addresses,defaults,parentAddresses))if(list.stream().map(r->id(r,"adt_id")).distinct().count()!=list.size())throw bad("Ein Adresstyp ist mehrfach hinterlegt."); + var types=db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='addresstype' AND mt_sort BETWEEN 1 AND 4 ORDER BY mt_sort"); + if(types.isEmpty())types=List.of(fields("id",1,"label","Leistungsanschrift"),fields("id",2,"label","Rechnungsanschrift"),fields("id",4,"label","Abhol-/Lieferadresse")); + if(types.stream().map(r->id(r,"id")).distinct().count()!=types.size())throw bad("Die Adresstypen sind nicht eindeutig."); + boolean locked=!new OrderEntryCatalog(db).parameter(0,"MASK_CSC_CMP_FIELDS_EDIT_DISABLED").isBlank(); + return new State(context,cs,current,tree,addresses,defaults,parentAddresses,types,external,locked); + } + static Object version(State s){return List.of(s.customer().id(),s.customer().root(),s.customer().base(),new TreeSet<>(s.customer().allowed()),s.context().rights(),s.current(),s.tree(),s.addresses(),s.defaults(),s.parentAddresses(),s.types(),s.external(),s.companyLocked());} + Map view(LegacyUser u,long customer,long center){return db.readTransaction(()->{ + var s=state(u,customer,center);boolean manage=s.context().can(1)&&!s.external(); + return fields("customer",customer,"center",center,"base",s.customer().base(),"name",text(s.current(),"csc_name"),"visible",id(s.current(),"csc_visible")==1, + "revision",revision(version(s)),"canManage",manage,"canVisibility",manage&¢er!=s.customer().base(),"canAddress",s.context().can(3),"companyLocked",s.companyLocked(),"external",s.external(), + "centers",s.customer().centers(),"children",s.customer().centers().stream().filter(r->id(r,"parent")==center).toList(),"types",s.types(),"addressFields",MasterData.CENTER_ADDRESS, + "addresses",s.context().can(3)?s.types().stream().map(r->fields("type",id(r,"id"),"values",displayAddress(s,id(r,"id")))).toList():List.of()); + });} + static Map addressValues(Map row){var values=new LinkedHashMap();for(var f:MasterData.CENTER_ADDRESS){String value=text(row,f.key());if(f.key().equals("ad_country")&&(value.isEmpty()||value.equals("D")))value="DE";values.put(f.key(),value);}return values;} + static Map displayAddress(State s,long type){var values=addressValues(s.addresses().stream().filter(r->id(r,"adt_id")==type).findFirst().orElse(Map.of()));if(text(values,"cscad_comp").isEmpty()){var parent=s.parentAddresses().stream().filter(r->id(r,"adt_id")==type).findFirst().orElse(Map.of());for(String k:List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"))values.put(k,text(parent,k));}return values;} + static String name(String value){value=required(ArticleAdministration.clean(value,50,"Kostenstellenname"),50,"Kostenstellenname");if(value.contains("//")||value.contains("\n")||value.contains("\r"))throw bad("Kostenstellennamen dürfen keine Pfadtrenner oder Zeilenumbrüche enthalten.");return value;} + void authorize(State s,String kind){if(kind.equals("ADDRESS"))s.context().require(3);else{ s.context().require(1);if(s.external())throw CustomerPortalScope.denied();}} + List references(){return db.queryForList("SELECT c.TABLE_NAME AS tbl,c.COLUMN_NAME AS col FROM information_schema.COLUMNS c JOIN information_schema.TABLES t ON t.TABLE_SCHEMA=c.TABLE_SCHEMA AND t.TABLE_NAME=c.TABLE_NAME WHERE c.TABLE_SCHEMA=DATABASE() AND t.TABLE_TYPE='BASE TABLE' AND c.COLUMN_NAME IN ('csc_id','csc_id_payer','csc_id_related','csc_pre_id') AND c.TABLE_NAME NOT IN ('costcenter','costcenteraddress') ORDER BY c.TABLE_NAME,c.COLUMN_NAME").stream().map(r->{String t=text(r,"tbl"),c=text(r,"col");if(!t.matches("[A-Za-z0-9_]+")||!c.matches("[A-Za-z0-9_]+"))throw bad("Ungültiger Kostenstellenbezug im Datenbankschema.");return new Reference(t,c);}).toList();} + void deletable(State s,long child,List refs){ + if(child==s.customer().root()||child==s.customer().base()||!s.customer().allowed().contains(child)||s.tree().stream().noneMatch(r->id(r,"csc_id")==child&&id(r,"csc_pre_id")==id(s.current(),"csc_id")))throw CustomerPortalScope.denied(); + if(s.tree().stream().anyMatch(r->id(r,"csc_pre_id")==child))throw bad("Die Kostenstelle hat noch Unterkostenstellen."); + for(var ref:refs)if(!db.queryForList("SELECT 1 FROM `"+ref.table()+"` WHERE `"+ref.column()+"`=? LIMIT 1",child).isEmpty())throw bad("Die Kostenstelle wird noch verwendet und kann nicht gelöscht werden."); + } + Plan plan(LegacyUser u,Command c,boolean validate,List refs){ + if(c==null||!Set.of("CREATE","RENAME","VISIBILITY","DELETE","ADDRESS").contains(Objects.toString(c.kind(),"")))throw bad("Ungültige Kostenstellenänderung.");token(c.requestId()); + var s=state(u,c.customer(),c.center());authorize(s,c.kind());unchanged(version(s),c.revision()); + var values=new LinkedHashMap();var changes=new ArrayList>(); + switch(c.kind()){ + case "CREATE","RENAME"->{String n=name(c.name());if(c.kind().equals("RENAME")&&n.equals(text(s.current(),"csc_name")))throw bad("Keine Namensänderung vorhanden."); + if(!db.queryForList("SELECT csc_id FROM costcenter WHERE cs_id=? AND csc_name=? AND csc_id<>?",c.customer(),n,c.kind().equals("RENAME")?c.center():0).isEmpty())throw bad("Dieser Kostenstellenname ist beim Kunden bereits vorhanden.");values.put("name",n);changes.add(fields("label",c.kind().equals("CREATE")?"Neue Unterkostenstelle":"Kostenstellenname","before",c.kind().equals("CREATE")?"Unter "+text(s.current(),"csc_name"):text(s.current(),"csc_name"),"after",n));} + case "VISIBILITY"->{if(c.center()==s.customer().base())throw bad("Die persönliche Hauptkostenstelle kann nicht ausgeblendet werden.");if((id(s.current(),"csc_visible")==1)==c.visible())throw bad("Die Sichtbarkeit ist unverändert.");values.put("visible",c.visible()?1:0);changes.add(fields("label","Sichtbarkeit","before",id(s.current(),"csc_visible")==1?"Sichtbar":"Ausgeblendet","after",c.visible()?"Sichtbar":"Ausgeblendet"));} + case "DELETE"->{deletable(s,c.child(),refs);changes.add(fields("label","Unterkostenstelle löschen","before",text(one(s.tree().stream().filter(r->id(r,"csc_id")==c.child()).toList()),"csc_name"),"after","Wird entfernt"));} + case "ADDRESS"->{if(c.address()==null||s.types().stream().noneMatch(r->id(r,"id")==c.addressType()))throw bad("Ungültiger Adresstyp."); + c.address().forEach((k,v)->ArticleAdministration.clean(Objects.toString(v,""),10000,k));values.putAll(MasterData.validateFields(MasterData.CENTER_ADDRESS,"centers",c.address())); + var old=addressValues(s.addresses().stream().filter(r->id(r,"adt_id")==c.addressType()).findFirst().orElse(Map.of())); + if(s.companyLocked())for(String k:List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"))if(!Objects.equals(displayAddress(s,c.addressType()).get(k),values.get(k)))throw CustomerPortalScope.denied(); + for(var f:MasterData.CENTER_ADDRESS)if(!Objects.equals(old.get(f.key()),values.get(f.key())))changes.add(fields("label",f.label(),"before",old.get(f.key()),"after",values.get(f.key()))); + if(changes.isEmpty())throw bad("Keine Adressänderung vorhanden.");if(validate)validateAddress(values,c.skipPostal()); + } + } + return new Plan(s,c,values,changes); + } + void validateAddress(Map v,boolean skipPostal){ + routing.geocode(new OrderEntryModels.Station(null,Models.StopType.PICKUP,text(v,"cscad_comp"),text(v,"ad_street"),text(v,"cscad_hsno"),text(v,"ad_zipcode"),text(v,"ad_city"),text(v,"ad_country"),"","","","",null,null,List.of())); + if(!skipPostal&&db.queryForList("SELECT st_zipcode FROM phoenix_special.street WHERE st_zipcode=? AND st_city LIKE ? ESCAPE '!' AND st_street LIKE ? ESCAPE '!' LIMIT 1",text(v,"ad_zipcode"),EntryCostCenterSearch.term(text(v,"ad_city"),true),EntryCostCenterSearch.term(WarehouseDetails.streetPrefix(text(v,"ad_street")),true)).isEmpty())throw bad("Postleitzahl, Ort und Straße passen nicht zum Straßenverzeichnis. Bitte korrigieren oder die zusätzliche PLZ-Prüfung ausdrücklich ausnehmen."); + } + static Object planVersion(Plan p){return List.of(version(p.state()),p.command().kind(),p.command().child(),p.command().addressType(),p.command().skipPostal(),p.values(),p.changes());} + Map preview(LegacyUser u,Command c){return db.readTransaction(()->{var refs=c!=null&&"DELETE".equals(c.kind())?references():List.of();var p=plan(u,c,true,refs);return fields("changes",p.changes(),"planRevision",revision(planVersion(p)));});} + Map save(LegacyUser u,Command c){ + if(c==null)throw bad("Kostenstellenänderung fehlt.");token(c.requestId());var intent=List.of("CUSTOMER_COSTCENTER",c);support.transactional("costcenter","costcenteraddress","address","company","genericdatacontainer"); + 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(),()->{ + 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()){ + case "CREATE"->{var root=one(s.tree().stream().filter(r->id(r,"csc_id")==s.customer().root()).toList());result=db.insert("costcenter",fields("cs_id",c.customer(),"csc_pre_id",c.center(),"csc_name",p.values().get("name"),"csc_visible",1,"csc_inv_interval",id(root,"csc_inv_interval"))); + for(int type=1;type<=4;type++){int selected=type;var original=s.defaults().stream().filter(r->id(r,"adt_id")==selected).findFirst().orElse(Map.of());var copy=fields("csc_id",result,"adt_id",type,"ad_id",id(original,"ad_id"),"cscad_hsno",text(original,"cscad_hsno"),"cscad_gln","");for(String k:List.of("cscad_comp","cscad_comp2","cscad_comp3","cscad_comp4"))copy.put(k,text(original,k));insertWithoutKey(db,"costcenteraddress",copy);}rebuildPaths(c.customer(),s.customer().root());operation=45;} + case "RENAME"->{db.update("UPDATE costcenter SET csc_name=? WHERE csc_id=?",p.values().get("name"),c.center());rebuildPaths(c.customer(),s.customer().root());operation=47;} + case "VISIBILITY"->{db.update("UPDATE costcenter SET csc_visible=? WHERE csc_id=?",p.values().get("visible"),c.center());operation=51;} + case "DELETE"->{db.update("DELETE FROM costcenteraddress WHERE csc_id=?",c.child());db.update("DELETE FROM costcenter WHERE csc_id=?",c.child());operation=46;} + case "ADDRESS"->{var v=p.values();var matches=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id",v.get("ad_street"),v.get("ad_zipcode"),v.get("ad_city"),v.get("ad_country"));long address=matches.isEmpty()?db.insert("address",MasterData.prefix(v,"ad_")):id(matches.getFirst(),"ad_id");var data=MasterData.prefix(v,"cscad_");data.put("ad_id",address); + if(s.addresses().stream().anyMatch(r->id(r,"adt_id")==c.addressType())){var args=new ArrayList<>(data.values());args.add(c.center());args.add(c.addressType());db.update("UPDATE costcenteraddress SET "+String.join(",",data.keySet().stream().map(k->k+"=?").toList())+" WHERE csc_id=? AND adt_id=?",args.toArray());}else{data.put("csc_id",c.center());data.put("adt_id",c.addressType());insertWithoutKey(db,"costcenteraddress",data);} + if(c.center()==s.customer().root()){var customer=one(db.queryForList("SELECT cmp_id FROM customer WHERE cs_id=?",c.customer()));db.update("UPDATE company SET cmp_modify_status=2 WHERE cmp_id=? AND COALESCE(cmp_modify_status,0) NOT IN (1,2)",id(customer,"cmp_id"));}operation=48;} + default->throw bad("Unbekannte Kostenstellenaktion."); + } + support.audit(u,0,c.customer(),0,operation,"CUSTOMER_COSTCENTER="+result+"|ACTION="+c.kind()+"|CHANGES="+support.encode(p.changes()),c.requestId());support.receipt(u,c.requestId(),intent,result);return fields("completed",true,"center",result); + }); + } + void rebuildPaths(long customer,long root){ + var rows=db.queryForList("SELECT csc_id AS id,csc_pre_id AS parent,csc_name AS label FROM costcenter WHERE cs_id=? ORDER BY csc_id",customer);var valid=PortalEmployees.validCenters(rows,root);var byId=new HashMap>();rows.forEach(r->byId.put(id(r,"id"),r)); + for(long center:valid){var names=new ArrayList();long cursor=id(byId.get(center),"parent");while(cursor>0){var p=byId.get(cursor);names.addFirst(text(p,"label"));cursor=id(p,"parent");}db.update("UPDATE costcenter SET csc_path=? WHERE csc_id=?",names.isEmpty()?"":"//"+String.join("//",names)+"//",center);} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCourierAssignment.java b/backend/src/main/java/de/stadtbote/votian/CustomerCourierAssignment.java new file mode 100644 index 0000000..367e103 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCourierAssignment.java @@ -0,0 +1,83 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** customer/jb_list.php: a customer may dispatch only to its own favoured couriers. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{job}/courier-assignment") +class CustomerCourierAssignment { + record Selection(long vehicle,long courier,boolean resetPreviousAvailableTime){} + record Command(String requestId,String revision,Selection selection,boolean confirmed){} + record State(long customer,String accessRevision,Map job,List> stops,List> vehicles,List blockers){} + record Prepared(State state,Selection selection,Map vehicle,boolean loseRanking,String rankingAfter,List warnings,String revision){} + final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog; + CustomerCourierAssignment(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;catalog=new OrderEntryCatalog(db);} + @GetMapping Object get(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);} + @PostMapping("/preview") Object preview(Authentication a,@PathVariable long job,@RequestBody Selection s){return preview(ApiController.user(a),job,s);} + @PostMapping Object post(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);} + + State state(LegacyUser u,long job){ + var context=CustomerPortalScope.context(db,u);context.require(4);long customer=id(context.account(),"cs_id"); + var own=CustomerPortalScope.customer(context,customer);var args=new ArrayList(new TreeSet<>(own.allowed()));args.add(job);args.add(u.headquartersId()); + var portal=new CustomerPortal(db);String related=catalog.parameter(0,"GLOBAL_USE_RELATED_CUSTOMER"),enabled=catalog.parameter(0,"CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"+customer); + if(!enabled.equals("1")||related.equals("1"))throw CustomerPortalScope.denied(); + var rows=db.queryForList("SELECT jb.* FROM job jb WHERE ("+portal.payer(u)+") IN ("+CourierPortal.placeholders(own.allowed().size())+") AND jb.jb_id=? AND jb.hq_id=?",args.toArray()); + if(rows.size()!=1||id(rows.getFirst(),"jb_id_parent")>0)throw CustomerPortalScope.denied();var j=rows.getFirst();var blockers=new ArrayList(); + if(!Set.of(0L,1L,8L,9L).contains(id(j,"jb_status"))||id(j,"jb_storno")!=0)blockers.add("Nur offene, nicht stornierte Aufträge können zugewiesen werden."); + if(OrderOperations.exported(j)||!db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",job).isEmpty()||!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())blockers.add("Der Auftrag ist exportiert oder einer Rechnung zugeordnet."); + if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())blockers.add("Für den Auftrag besteht bereits eine Gegenbuchung."); + var lock=dateTime(j,"jb_locktime");if(lock!=null&&lock.isAfter(db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().minusMinutes(5)))blockers.add("Der Auftrag wird gerade in der alten Anwendung bearbeitet."); + if(id(j,"hq_id_dispo")>0&&id(j,"hq_id_dispo")!=u.headquartersId())blockers.add("Die Zuweisung erfolgt durch eine andere Niederlassung."); + var stops=db.queryForList("SELECT t.tr_id,t.tr_sort,stopaddr.ad_zipcode FROM tour t LEFT JOIN address stopaddr ON stopaddr.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job); + if(stops.isEmpty()||text(stops.getFirst(),"ad_zipcode").isBlank())blockers.add("Die Startadresse ist unvollständig."); + boolean multiple=AssignmentVehicles.multiple(catalog,u); + String sql="SELECT cv.crvh_id,cv.crvh_sid,cv.vht_id,cr.cr_sid,cr.cr_available,cr.cr_occupied,cr.cr_locationzipcode,cr.cr_availabletime,"+AssignmentVehicles.IDENTITY+" "+AssignmentVehicles.from(false,multiple)+" AND cr.cr_sid=cv.crvh_sid AND EXISTS (SELECT 1 FROM customercourier fav WHERE fav.cs_id=? AND fav.cr_id=cr.cr_id AND fav.cscr_relation=1) AND NOT EXISTS (SELECT 1 FROM customercourier blocked WHERE blocked.cs_id=? AND blocked.cr_id=cr.cr_id AND blocked.cscr_relation<>1) ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 1001"; + var vehicles=AssignmentVehicles.identities(db.queryForList(sql,u.headquartersId(),customer,customer)).stream().filter(v->!AssignmentVehicles.ambiguous(v,multiple)&&id(v,"logged_drivers")==1).toList(); + if(vehicles.size()>1000)throw bad("Zu viele bevorzugte Fahrzeuge. Bitte die Zuordnungen prüfen."); + String access=revision(List.of(customer,own.allowed(),context.rights(),enabled,related,multiple)); + return new State(customer,access,j,stops,vehicles,List.copyOf(blockers)); + } + Map options(LegacyUser u,long job){return db.readTransaction(()->{var s=state(u,job);return fields("id",job,"current",text(s.job(),"cr_sid"),"canResetPrevious",id(s.job(),"cr_id")>0,"blockers",s.blockers(),"vehicles",s.vehicles().stream().map(v->fields("id",id(v,"crvh_id"),"courier",id(v,"cr_id"),"sid",text(v,"crvh_sid"),"available",id(v,"cr_available")==1,"occupied",id(v,"cr_occupied")!=0)).toList());});} + Prepared prepare(LegacyUser u,long job,Selection selection){ + if(selection==null||selection.vehicle()<=0||selection.courier()<=0)throw bad("Bitte einen bevorzugten Kurier wählen."); + var s=state(u,job);if(!s.blockers().isEmpty())throw bad(String.join(" · ",s.blockers())); + var matches=s.vehicles().stream().filter(v->id(v,"crvh_id")==selection.vehicle()&&id(v,"cr_id")==selection.courier()).toList(); + if(matches.size()!=1)throw CustomerPortalScope.denied();var v=matches.getFirst();var j=s.job(); + if(id(j,"cr_id_order")==selection.courier()||id(j,"cr_id")==selection.courier())throw bad("Dieser Kurier ist dem Auftrag bereits zugewiesen."); + if(selection.resetPreviousAvailableTime()&&(id(j,"cr_id")<=0||db.queryForList("SELECT cr_id FROM courier WHERE cr_id=? AND hq_id=?",id(j,"cr_id"),u.headquartersId()).size()!=1))throw bad("Es gibt keinen bisherigen Kurier mit zurücksetzbarer Freimeldezeit."); + var warnings=new ArrayList();if(id(v,"cr_available")!=1)warnings.add("Der Kurier ist nicht als verfügbar gemeldet.");if(id(v,"cr_occupied")!=0)warnings.add("Der Kurier ist bereits als belegt gemeldet.");if(id(v,"vht_id")!=id(j,"vht_id"))warnings.add("Die Fahrzeugklasse weicht vom Auftrag ab."); + var evidence=new ArrayList();evidence.add(s);evidence.add(selection);String start=text(s.stops().getFirst(),"ad_zipcode");long hq=id(j,"hq_id_dispo")>0?id(j,"hq_id_dispo"):u.headquartersId(); + var lead=db.queryForList("SELECT p.srvp_id,lt.srvpt_traveltime FROM serviceplz p JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id WHERE p.srvp_plz=? AND lt.hq_id=? ORDER BY p.srvp_id",start,hq);evidence.add(lead); + if(lead.size()>1)throw bad("Die Anfahrtszeit für den Rankingverlust ist mehrdeutig."); + String after="";boolean deferred=false;var time=dateTime(j,"jb_ordertime"); + if(time!=null&&lead.size()==1&&lead.getFirst().get("srvpt_traveltime")!=null){var threshold=time.minusMinutes(Math.max(30,id(lead.getFirst(),"srvpt_traveltime")));after=threshold.toString();deferred=!db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().isAfter(threshold);} + boolean lose=!deferred&&new FleetRanking(db,catalog).area(u,text(v,"cr_locationzipcode"),start,evidence).contains(); + if(deferred)warnings.add("Künftiger Auftrag: Rankingverlust wird erst nach "+after.replace('T',' ')+" geprüft."); + evidence.add(after);evidence.add(deferred);evidence.add(lose);return new Prepared(s,selection,v,lose,after,List.copyOf(warnings),revision(evidence)); + } + Map preview(LegacyUser u,long job,Selection selection){return db.readTransaction(()->{var p=prepare(u,job,selection);return fields("id",job,"revision",p.revision(),"sid",text(p.vehicle(),"crvh_sid"),"previous",text(p.state().job(),"cr_sid"),"loseRanking",p.loseRanking(),"rankingAfter",p.rankingAfter(),"warnings",p.warnings(),"resetPreviousAvailableTime",selection.resetPreviousAvailableTime());});} + Map save(LegacyUser u,long job,Command c){ + if(c==null||!c.confirmed()||c.selection()==null||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Bitte die Zuweisung zuerst prüfen und bestätigen.");BusinessSupport.token(c.requestId()); + support.transactional("job","courier","autoranking","genericdatacontainer","phoenix_pda.commandexec"); + return db.customerAssignmentWriteTransaction(()->{ + state(u,job);Object intent=List.of("CUSTOMER_ASSIGN",job,c);Long old=support.receipt(u,c.requestId(),intent);if(old!=null)return fields("id",job,"replayed",true); + var p=prepare(u,job,c.selection());if(!p.revision().equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Auftrag, Fahrer oder Freigabe wurden geändert. Bitte erneut prüfen."); + var j=p.state().job();long next=c.selection().courier();var now=LocalDateTime.now().withNano(0); + for(long previous:new TreeSet<>(List.of(id(j,"cr_id"),id(j,"cr_id_order"))))if(previous>0&&previous!=next)db.insert("`phoenix_pda`.commandexec",fields("cr_id",previous,"hq_id",u.headquartersId(),"cmd_command","4","cmde_mode","1","cmde_param",Long.toString(job),"cmde_exec_time",now.plusSeconds(30),"cmde_exec_timelimit",now.plusHours(36))); + if(c.selection().resetPreviousAvailableTime())db.update("UPDATE courier SET cr_availabletime=? WHERE cr_id=? AND hq_id=?",now,id(j,"cr_id"),u.headquartersId()); + var ranks=db.queryForList("SELECT cr_id FROM autoranking WHERE jb_id=? AND cr_id=?",job,next);if(ranks.size()>1)throw bad("Die bisherige Kurierzuweisung ist mehrdeutig."); + if(ranks.isEmpty())BusinessSupport.insertWithoutKey(db,"autoranking",fields("jb_id",job,"cr_id",next,"ar_challenge",0,"ar_lastassigntime",now,"ar_looseranking",p.loseRanking()?1:0,"ar_locating",0)); + else db.update("UPDATE autoranking SET ar_challenge=0,ar_lastassigntime=?,ar_looseranking=?,ar_locating=0 WHERE jb_id=? AND cr_id=?",now,p.loseRanking()?1:0,job,next); + var changes=fields("cr_id",0,"cr_id_order",next,"cr_sid",text(p.vehicle(),"crvh_sid"),"jb_status",0,"jb_globaljob",0,"jb_autoranking",0,"jb_taketime",null,"vht_id_real",id(p.vehicle(),"vht_id"),"jb_version",id(j,"jb_version")+1);BusinessSupport.update(db,"job","jb_id",job,changes); + var assigned=new LinkedHashMap<>(j);assigned.putAll(changes);new OrderSurvey(db,catalog).apply(u,assigned,c.requestId(),support); + support.audit(u,job,p.state().customer(),next,7,"Kundenzuweisung · LOST_RANKING="+(p.loseRanking()?"YES":"NO"),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/CustomerCourierRelations.java b/backend/src/main/java/de/stadtbote/votian/CustomerCourierRelations.java new file mode 100644 index 0000000..45682dd --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCourierRelations.java @@ -0,0 +1,120 @@ +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 static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** customer_courier.php/courier_customer.php, with durable recovery for customercourier (MyISAM). */ +@RestController +@RequestMapping("/api/master/relations") +class CustomerCourierRelations { + final DatabaseService db;final BusinessSupport support;final Clock clock; + @org.springframework.beans.factory.annotation.Autowired + CustomerCourierRelations(DatabaseService db,BusinessSupport support){this(db,support,Clock.systemDefaultZone());} + CustomerCourierRelations(DatabaseService db,BusinessSupport support,Clock clock){this.db=db;this.support=support;this.clock=clock;} + record Row(long customer,long courier,int relation,int position,String commission,String description){} + record Target(long customer,long courier,String revision){} + record Command(String requestId,String action,List targets,Integer relation,String description,String commission,Integer position,String planRevision){} + record Change(long customer,List before,List after){} + record History(String eid,String value,String at){} + record Commission(long customer,long courier,String eid,String value,List before){} + record Prepared(List changes,List commissions,List descriptions){} + record Plan(long user,long headquarters,Command command,Prepared prepared,String at){} + @GetMapping("/{kind}/{id}") Map get(Authentication a,@PathVariable String kind,@PathVariable long id){return view(ApiController.user(a),kind,id);} + @GetMapping("/search") Map search(Authentication a,@RequestParam String kind,@RequestParam(defaultValue="") String q){return search(ApiController.user(a),kind,q);} + @GetMapping("/history") List history(Authentication a,@RequestParam long customer,@RequestParam long courier){var u=ApiController.user(a);pair(u,customer,courier);return history(customer,courier);} + @PostMapping("/preview") Map preview(Authentication a,@RequestBody Command c){return preview(ApiController.user(a),c);} + @PostMapping Map save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + @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;} + 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);} + Plan decode(String json){try{return support.json.readValue(json,Plan.class);}catch(Exception e){throw bad("Der gespeicherte Beziehungsvorgang ist nicht lesbar.");}} + Map view(LegacyUser u,String kind,long target){access(u,kind,target);return db.readTransaction(()->{ + String column=kind.equals("customers")?"r.cs_id":"r.cr_id"; + var records=db.queryForList("SELECT r.cs_id,r.cr_id,r.cscr_relation,r.cscr_sort,r.cscr_prov,r.cscr_description,cs.cs_eid,CONCAT_WS(' ',co.cmp_comp,co.cmp_comp2) AS customerName,cr.cr_eid,cr.cr_sid,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS courierName FROM customercourier r JOIN customer cs ON cs.cs_id=r.cs_id JOIN courier cr ON cr.cr_id=r.cr_id LEFT JOIN company co ON co.cmp_id=cs.cmp_id LEFT JOIN user usr ON usr.usr_id=cr.usr_id WHERE "+column+"=? AND cs.hq_id=? AND cr.hq_id=? ORDER BY r.cscr_relation DESC,r.cscr_sort,r.cs_id,r.cr_id LIMIT 1001",target,u.headquartersId(),u.headquartersId()); + if(records.size()>1000)throw bad("Mehr als 1000 Beziehungen. Bitte den Bestand eingrenzen lassen."); + var revisions=new HashMap();if(kind.equals("customers"))revisions.put(target,revision(rows(target))); + var list=new ArrayList>();for(var r:records){long customer=id(r,"cs_id");String rev=revisions.computeIfAbsent(customer,k->revision(rows(k)));list.add(fields("customer",customer,"courier",id(r,"cr_id"),"customerLabel",text(r,"cs_eid")+" · "+text(r,"customerName"),"courierLabel",text(r,"cr_eid")+" / "+text(r,"cr_sid")+" · "+text(r,"courierName"),"relation",id(r,"cscr_relation"),"position",id(r,"cscr_sort"),"commission",amount(r.get("cscr_prov")),"description",text(r,"cscr_description"),"revision",rev));} + var operations=new ArrayList>();for(var r:pending(u.headquartersId())){var p=decode(text(r,"gdc_content"));if(p.command().targets().stream().anyMatch(t->kind.equals("customers")?t.customer()==target:t.courier()==target)||p.prepared().changes().stream().anyMatch(ch->revisions.containsKey(ch.customer())))operations.add(fields("token",text(r,"gdc_gen_fieldname"),"canResume",p.user()==u.userId(),"description",String.join(" ",p.prepared().descriptions())));} + return fields("rows",list,"revision",revisions.getOrDefault(target,""),"canEditCommission",u.hasRight(39),"operations",operations); + });} + Map search(LegacyUser u,String kind,String query){u.require(1);if(query==null||query.length()>100)throw bad("Suchtext darf höchstens 100 Zeichen lang sein.");String q="%"+query.trim()+"%";List> found; + if(kind.equals("customers"))found=db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND (cs.cs_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_comp2 LIKE ?) ORDER BY co.cmp_comp,cs.cs_id LIMIT 51",u.headquartersId(),q,q,q); + else if(kind.equals("couriers"))found=db.queryForList("SELECT cr.cr_id AS id,CONCAT_WS(' · ',cr.cr_eid,cr.cr_sid,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name)) AS label FROM courier cr LEFT JOIN user usr ON usr.usr_id=cr.usr_id WHERE cr.hq_id=? AND (cr.cr_eid LIKE ? OR cr.cr_sid LIKE ? OR usr.usr_name LIKE ? OR usr.usr_firstname LIKE ?) ORDER BY cr.cr_sid,cr.cr_id LIMIT 51",u.headquartersId(),q,q,q,q); + else throw bad("Unbekannter Suchbereich.");return fields("items",found.stream().limit(50).toList(),"more",found.size()>50); + } + 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());} + 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(); + if(c.action().equals("ADD")&&old!=null)throw bad("Zwischen diesem Kunden und Transporteur besteht bereits eine Beziehung."); + if(!c.action().equals("ADD")&&old==null)throw bad("Die ausgewählte Beziehung besteht nicht mehr."); + 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.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."); + String rate=old==null?"0.00":old.commission(); + 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; + 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())));} + 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 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());});} + void ensureAvailable(LegacyUser u,List changes){var pending=pending(u.headquartersId());if(pending.size()>100)throw bad("Zu viele offene Beziehungsvorgänge. Bitte zuerst abschließen.");for(var row:pending){var plan=decode(text(row,"gdc_content"));if(plan.prepared().changes().stream().anyMatch(c->changes.stream().anyMatch(n->n.customer()==c.customer())))throw bad("Für einen betroffenen Kunden ist noch ein gesicherter Beziehungsvorgang offen. Bitte zuerst fortsetzen.");}} + Map save(LegacyUser u,Command c){ + validate(u,c);support.transactional("genericdatacontainer","servicepricehistory"); + db.customerRelationWriteTransaction(()->{ + var existing=operation(u.headquartersId(),c.requestId());if(!existing.isEmpty()){var p=decode(text(one(existing),"gdc_content"));if(p.user()!=u.userId()||!p.command().equals(c))throw bad("Diese Vorgangskennung gehört zu anderen Daten.");return null;} + var prepared=prepare(u,c);unchanged(prepared,c.planRevision());ensureAvailable(u,prepared.changes());var at=LocalDateTime.now(clock).withNano(0); + for(var commission:prepared.commissions())if(commission.before().stream().anyMatch(h->!LocalDateTime.parse(h.at().replace(' ','T')).isBefore(at)))throw bad("Für diese Provision existiert bereits ein Stand in dieser Sekunde oder in der Zukunft. Bitte später erneut prüfen."); + var plan=new Plan(u.userId(),u.headquartersId(),c,prepared,at.toString());String json=support.encode(plan);if(json.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Beziehungen für einen Vorgang. Bitte die Auswahl verkleinern."); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngcscr","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",json,"gdc_context","PENDING"));return null; + });return resume(u,c.requestId()); + } + Map resume(LegacyUser u,String token){u.require(1);token(token);support.transactional("genericdatacontainer","servicepricehistory");var p=decode(text(one(operation(u.headquartersId(),token)),"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId())throw bad("Nur der ursprüngliche Bearbeiter kann den Vorgang fortsetzen.");validate(u,p.command());if(!p.prepared().commissions().isEmpty())u.require(39); + try{return db.customerRelationWriteTransaction(()->{ + if(text(one(operation(u.headquartersId(),token)),"gdc_context").equals("DONE"))return fields("state","DONE","token",token); + validate(u,p.command());for(var change:p.prepared().changes())if(!recoverable(rows(change.customer()),change))throw bad("Die Beziehungen wurden außerhalb dieses Vorgangs geändert. Bitte den Konflikt im Bestand klären."); + for(var commission:p.prepared().commissions())if(!history(commission.customer(),commission.courier()).equals(commission.before()))throw bad("Die Provisionshistorie wurde zwischenzeitlich geändert. Bitte den Konflikt im Bestand klären."); + for(var change:p.prepared().changes())apply(change); + for(var commission:p.prepared().commissions())insertWithoutKey(db,"servicepricehistory",fields("srvprch_obj_type","cr","srvprch_obj_id",commission.courier(),"srvprch_obj_eid",commission.eid(),"srvprch_fieldname","cscr_prov","srvprch_context",Long.toString(commission.customer()),"srvprch_price",new BigDecimal(commission.value()),"srvprch_validfrom",LocalDateTime.parse(p.at()))); + int operation=p.command().action().equals("ADD")?181:p.command().action().equals("REMOVE")?182:p.prepared().commissions().isEmpty()?37:180; + for(int k=0;k current,Change change){var before=index(change.before());var after=index(change.after());var actual=index(current);var keys=new HashSet<>(before.keySet());keys.addAll(after.keySet());keys.addAll(actual.keySet());return keys.stream().allMatch(k->Objects.equals(actual.get(k),before.get(k))||Objects.equals(actual.get(k),after.get(k)));} + static Map index(List rows){var out=new HashMap();rows.forEach(r->out.put(r.courier(),r));return out;} + void apply(Change change){var actual=index(rows(change.customer()));var after=index(change.after());var keys=new TreeSet<>(actual.keySet());keys.addAll(after.keySet());for(long key:keys){var before=actual.get(key);var desired=after.get(key);if(Objects.equals(before,desired))continue; + if(desired==null)db.update("DELETE FROM customercourier WHERE cs_id=? AND cr_id=?",change.customer(),key); + else if(before==null)db.update("INSERT INTO customercourier (cs_id,cr_id,cscr_relation,cscr_sort,cscr_prov,cscr_description) VALUES (?,?,?,?,?,?)",desired.customer(),desired.courier(),desired.relation(),desired.position(),new BigDecimal(desired.commission()),desired.description()); + else db.update("UPDATE customercourier SET cscr_relation=?,cscr_sort=?,cscr_prov=?,cscr_description=? WHERE cs_id=? AND cr_id=?",desired.relation(),desired.position(),new BigDecimal(desired.commission()),desired.description(),desired.customer(),desired.courier()); + }} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java b/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java new file mode 100644 index 0000000..c7fe825 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerCreationDefaults.java @@ -0,0 +1,38 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** PHP's HQ-specific defaults are applied atomically with a new customer. */ +record CustomerCreationDefaults(boolean automaticDispatch,boolean cargoRequired,Map mailDefaults,String mailFields,String revision) { + static final List KEYS=List.of("MASK_MANUAL_DISPOSITION","MASK_MANUAL_DISPOSITION_CUSTOMER_MANDATORY","MASK_CS_CARGO_DATA_ENABLED","CS_JBSTATUSMAIL_FIELDS_ENABLED","CS_JBSTATUSMAIL_FIELD3_DEFAULT","CS_JBSTATUSMAIL_FIELD9_DEFAULT","CS_JBSTATUSMAIL_FIELDA_DEFAULT"); + CustomerCreationDefaults(boolean automaticDispatch,boolean cargoRequired,String revision){this(automaticDispatch,cargoRequired,Map.of(),"0000000000",revision);} + static CustomerCreationDefaults read(DatabaseService db,LegacyUser u){ + var args=new ArrayList();args.add(u.headquartersId());args.addAll(KEYS); + var rows=db.queryForList("SELECT par_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key IN ("+String.join(",",Collections.nCopies(KEYS.size(),"?"))+") ORDER BY par_key,par_id",args.toArray()); + var config=new HashMap();for(var r:rows){String key=text(r,"par_key");if(config.put(key,text(r,"par_value"))!=null)throw bad("Mehrdeutige Startvorgaben für neue Kunden.");} + String[] events=config.getOrDefault(KEYS.get(3),"").split("\\|",-1);if(events.length>3)throw bad("Die E-Mail-Startvorgabe enthält mehr als drei Ereignisse. Bitte den Bestand klären."); + var mail=new LinkedHashMap();var eventKeys=List.of("cs_jbstatusmail","cs_jbstatusmail2","cs_jbstatusmail3"); + for(int i=0;i1||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()); + 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."); + db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",0,"emp_id",0,"par_value","1","par_text","")); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerDashboard.java b/backend/src/main/java/de/stadtbote/votian/CustomerDashboard.java new file mode 100644 index 0000000..c61c20c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerDashboard.java @@ -0,0 +1,56 @@ +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.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.function.Supplier; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Native customer file, corresponding to dashb_cs.php and its current GET service contracts. */ +@RestController +@RequestMapping("/api/crm/customer-file") +class CustomerDashboard { + final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog; + CustomerDashboard(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog){this.db=db;this.support=support;this.catalog=catalog;} + static void access(LegacyUser u){u.require(0);u.require(11);u.require(36);} + @GetMapping("/customers") List> search(Authentication a,@RequestParam(defaultValue="")String q){return search(ApiController.user(a),q);} + @GetMapping("/{customer}") Map view(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer,LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));} + List> search(LegacyUser u,String q){access(u);if(q==null||q.length()>100)throw bad("Suchtext darf höchstens 100 Zeichen lang sein.");String like="%"+q.strip().replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' · ',c.cs_eid,co.cmp_comp,NULLIF(co.cmp_comp2,'')) AS label FROM customer c JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1 AND (c.cs_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_comp2 LIKE ? OR co.cmp_match LIKE ?) ORDER BY co.cmp_comp,c.cs_eid,c.cs_id LIMIT 50",u.headquartersId(),like,like,like,like);} + static Map unavailable(String message){return fields("available",false,"message",message);} + static Map section(Supplier> source){try{var result=new LinkedHashMap<>(source.get());result.put("available",true);return result;}catch(org.springframework.dao.DataAccessException e){return unavailable("Dieser Teil der Kundenakte konnte nicht gelesen werden. Bitte erneut laden.");}} + Map readSection(Supplier> source){return section(()->db.readTransaction(source));} + record Window(LocalDateTime year,LocalDateTime reportsSince,LocalDateTime appointmentsSince,boolean previousLogin){} + static Window window(LocalDateTime now,List logins){LocalDateTime year=now.minusDays(now.getDayOfYear()-1);LocalDateTime prior=logins.size()>1?logins.get(1):null;return new Window(year,prior==null?year:prior,prior==null?now:prior,prior!=null);} + Window window(LegacyUser u,LocalDateTime now){return window(now,LoginActivity.recent(db,support,u,now.minusDays(now.getDayOfYear()-1),now));} + Map view(LegacyUser u,long customer,LocalDateTime now){access(u);if(customer<=0)throw bad("Bitte einen Kunden auswählen."); + var owner=one(db.queryForList("SELECT c.cs_id,c.cs_eid,c.cmp_id,c.cs_admin,c.cs_become_cs_date,co.cmp_comp,co.cmp_comp2,co.cmp_comp3,co.cmp_comp4,co.cmp_hsno,co.cmp_bank,co.cmp_bankno,co.cmp_bankacc,co.cmp_iban,co.cmp_swift,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country,usr.usr_firstname,usr.usr_name,usr.usr_email,usr.usr_phone,usr.usr_phone2,usr.usr_fax FROM customer c JOIN company co ON co.cmp_id=c.cmp_id LEFT JOIN address a ON a.ad_id=co.ad_id LEFT JOIN employee e ON e.emp_id=c.cs_admin LEFT JOIN user usr ON usr.usr_id=e.usr_id WHERE c.cs_id=? AND c.hq_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1",customer,u.headquartersId())); + var profile=new LinkedHashMap();for(String key:List.of("cs_eid","cs_become_cs_date","cmp_comp","cmp_comp2","cmp_comp3","cmp_comp4","cmp_hsno","cmp_bank","cmp_bankno","cmp_bankacc","cmp_iban","cmp_swift","ad_street","ad_zipcode","ad_city","ad_country","usr_firstname","usr_name","usr_email","usr_phone","usr_phone2","usr_fax"))profile.put(key,text(owner,key)); + var result=fields("customer",customer,"headquarters",u.headquartersId(),"label",text(owner,"cs_eid")+" · "+text(owner,"cmp_comp"),"asOf",now,"profile",profile,"canOrders",u.hasRight(4),"canReports",u.hasRight(16),"canStatistics",u.hasRight(8),"canCouriers",u.hasRight(1)); + result.put("centers",readSection(()->centers(customer))); + result.put("drivers",u.hasRight(1)?readSection(()->drivers(u,customer)):unavailable("Für Fahrerbeziehungen fehlt das Transporteurrecht.")); + result.put("statistics",u.hasRight(8)?readSection(()->statistics(u,customer,now.getYear())):unavailable("Für Umsatz und Auftragsanzahl fehlt das Statistikrecht.")); + result.put("lastOrder",u.hasRight(4)?readSection(()->lastOrder(u,customer)):unavailable("Für den letzten Auftrag fehlt das Auftragsrecht.")); + if(u.hasRight(16))result.put("yearReports",readSection(()->reports(u,customer,now.minusDays(now.getDayOfYear()-1),now)));else result.put("yearReports",unavailable("Für die Berichte fehlt das Berichtsrecht.")); + try{var window=window(u,now);result.put("loginAvailable",true);result.put("previousLogin",window.previousLogin());result.put("sinceReports",u.hasRight(16)?readSection(()->reports(u,customer,window.reportsSince(),now)):unavailable("Für die Berichte fehlt das Berichtsrecht."));result.put("appointments",readSection(()->appointments(u,customer,window.appointmentsSince())));} + catch(org.springframework.dao.DataAccessException e){result.put("loginAvailable",false);result.put("previousLogin",false);result.put("sinceReports",unavailable("Der vorige Anmeldezeitpunkt konnte nicht gelesen werden."));result.put("appointments",unavailable("Der vorige Anmeldezeitpunkt konnte nicht gelesen werden."));} + return result; + } + Map centers(long customer){var rows=db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent,csc_visible AS visible,csc_blocked AS blocked FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name,csc_id LIMIT 2001",customer);return fields("rows",rows.stream().limit(2000).toList(),"truncated",rows.size()>2000);} + Map drivers(LegacyUser u,long customer){ + var rows=db.queryForList("SELECT r.cr_id AS id,r.cscr_relation AS relation,r.cscr_sort AS position,r.cscr_description AS description,c.cr_eid AS number,c.cr_sid AS vehicle,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS name,CONCAT_WS(' ',co.cmp_comp,co.cmp_comp2) AS company,CONCAT_WS(' ',a.ad_street,co.cmp_hsno) AS street,a.ad_zipcode AS postal,a.ad_city AS city,a.ad_country AS country FROM customercourier r JOIN courier c ON c.cr_id=r.cr_id LEFT JOIN company co ON co.cmp_id=c.cmp_id LEFT JOIN address a ON a.ad_id=co.ad_id LEFT JOIN user usr ON usr.usr_id=c.usr_id WHERE r.cs_id=? AND c.hq_id=? AND r.cscr_relation IN (0,1) ORDER BY r.cscr_relation DESC,r.cscr_sort,c.cr_eid,c.cr_id LIMIT 1001",customer,u.headquartersId()); + // Keep one row per relation even when a courier has several vehicles. + var vehicles=db.queryForList("SELECT v.cr_id,v.crvh_sid FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE c.hq_id=? AND EXISTS (SELECT 1 FROM customercourier r WHERE r.cs_id=? AND r.cr_id=c.cr_id AND r.cscr_relation IN (0,1)) ORDER BY v.cr_id,v.crvh_sid,v.crvh_id LIMIT 10001",u.headquartersId(),customer); + var result=rows.stream().limit(1000).map(r->{var copy=new LinkedHashMap<>(r);copy.put("vehicles",vehicles.stream().filter(v->id(v,"cr_id")==id(r,"id")).map(v->text(v,"crvh_sid")).distinct().toList());return copy;}).toList();return fields("rows",result,"truncated",rows.size()>1000||vehicles.size()>10000); + } + Map lastOrder(LegacyUser u,long customer){var rows=db.queryForList("SELECT j.jb_id AS id,j.jb_ordertime AS ordered,j.jb_status AS status FROM job j JOIN costcenter c ON c.csc_id=j.csc_id_payer WHERE c.cs_id=? AND j.hq_id=? AND COALESCE(j.jb_storno,0) IN (0,1) ORDER BY j.jb_id DESC LIMIT 1",customer,u.headquartersId());return fields("item",rows.isEmpty()?null:rows.getFirst());} + Map reports(LegacyUser u,long customer,LocalDateTime from,LocalDateTime to){var args=new ArrayList(List.of(customer,u.headquartersId(),from,to));String where="r.rp_objtype='cs' AND r.rp_objid=? AND r.hq_id=? AND r.rp_createtime>=? AND r.rp_createtime<=?";if(!u.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(u.userId());} + long count=db.queryForObject("SELECT COUNT(*) FROM "+ReportAdministration.TABLE+" r WHERE "+where,Long.class,args.toArray());var rows=db.queryForList("SELECT r.rp_id AS id,r.rp_createtime AS created,r.rp_text AS text,r.rp_confidential AS confidential,r.rp_reporttype AS type,(SELECT MIN(mt_value) FROM metatype WHERE mt_type='report_type' AND mt_sort=r.rp_reporttype) AS typeLabel,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS author FROM "+ReportAdministration.TABLE+" r LEFT JOIN user usr ON usr.usr_id=r.usr_id WHERE "+where+" ORDER BY r.rp_id DESC LIMIT 10",args.toArray());return fields("from",from,"to",to,"total",count,"rows",rows);} + Map appointments(LegacyUser u,long customer,LocalDateTime from){var args=new Object[]{customer,u.headquartersId(),from,u.userId(),"%,"+u.userId()+",%"};String where="a.cs_id=? AND a.hq_id=? AND a.ap_execdate>=? AND (a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))"; + long count=db.queryForObject("SELECT COUNT(*) FROM "+AppointmentService.TABLE+" a WHERE "+where,Long.class,args);var rows=db.queryForList("SELECT a.ap_id AS id,a.ap_execdate AS startsAt,a.ap_enddate AS endsAt,a.ap_text AS text,a.ap_category_3 AS kind,(SELECT MIN(mt_value) FROM metatype WHERE mt_type='appointment_category_3' AND mt_sort=a.ap_category_3) AS kindLabel,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS author FROM "+AppointmentService.TABLE+" a LEFT JOIN user usr ON usr.usr_id=a.usr_id WHERE "+where+" ORDER BY a.ap_execdate DESC,a.ap_id DESC LIMIT 10",args);return fields("from",from,"total",count,"rows",rows);} + Map statistics(LegacyUser u,long customer,int year){boolean related=catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"),exclude=catalog.enabled(u.headquartersId(),"STATISTIC_NO_STORNOJOBS");String payer=related?"csc_id_related":"csc_id_payer";var rows=db.queryForList("SELECT DATE_FORMAT(j.jb_finishtime,'%Y-%m') AS month,COUNT(*) AS orders,COALESCE(SUM(j.jb_totalprice),0) AS amount FROM job j JOIN costcenter c ON c.csc_id=j."+payer+" WHERE c.cs_id=? AND j.hq_id=? AND j.jb_status=2 AND j.jb_finishtime>=? AND j.jb_finishtime>();var years=new ArrayList>();for(int y=year-2;y<=year;y++){long orders=0;BigDecimal amount=BigDecimal.ZERO;for(int m=1;m<=12;m++){String key=YearMonth.of(y,m).toString();var found=rows.stream().filter(r->key.equals(text(r,"month"))).toList();if(found.size()>1)throw bad("Mehrdeutiger Statistikmonat.");long n=found.isEmpty()?0:id(found.getFirst(),"orders");BigDecimal v=found.isEmpty()?BigDecimal.ZERO:decimal(found.getFirst(),"amount");orders+=n;amount=amount.add(v);months.add(fields("month",key,"orders",n,"amount",BillingService.money(v)));}years.add(fields("year",y,"orders",orders,"amount",BillingService.money(amount)));}return fields("years",years,"months",months,"relatedCustomer",related,"excludeFreeCancellations",exclude);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEmployees.java b/backend/src/main/java/de/stadtbote/votian/CustomerEmployees.java new file mode 100644 index 0000000..37b5fd7 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEmployees.java @@ -0,0 +1,106 @@ +package de.stadtbote.votian; + +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; +import static de.stadtbote.votian.BusinessSupport.*; + +/** Customer variants of admin/employee_list.php, employee.php and employee_csc_rights.php. */ +@RestController +@RequestMapping("/api/customer-portal/employees") +class CustomerEmployees { + record Query(long customer,long center,boolean descendants,String search,String sort,boolean descending,int page) {} + record Command(String requestId,long customer,long employee,long center,String kind,String revision, + PortalEmployees.Profile profile,List rights,List centers, + String account,String currentPassword,String password,String confirmation,String code) { + @Override public String toString(){return "CustomerEmployeeCommand[redacted]";} + } + record Context(CustomerPortalScope.Context scope,CustomerPortalScope.Customer customer,long headquarters,long root, + boolean editable,boolean matrix,boolean matrixEnabled,boolean noSelfReset,List actorGrants,String heritage) {} + record State(Context context,Map person,List grants,boolean root,boolean self) {} + static final Map SORTS=Map.of("name","user.usr_name","firstName","user.usr_firstname","email","user.usr_email","phone","user.usr_phone","mobile","user.usr_phone2","account","user.usr_account","center","costcenter.csc_name"); + final DatabaseService db;final BusinessSupport support;final PortalEmployees shared; + CustomerEmployees(DatabaseService db,BusinessSupport support,PortalEmployees shared){this.db=db;this.support=support;this.shared=shared;} + @PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @GetMapping("/{customer}/{employee}") Object view(Authentication a,@PathVariable long customer,@PathVariable long employee){return view(ApiController.user(a),customer,employee);} + @PostMapping("/save") Object save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + + Context context(LegacyUser u,long customer){ + var scope=CustomerPortalScope.context(db,u);var cs=CustomerPortalScope.customer(scope,customer); + var entity=one(db.queryForList("SELECT customer.hq_id,customer.cs_admin FROM customer JOIN headquarters ON headquarters.hq_id=customer.hq_id WHERE customer.cs_id=? AND COALESCE(headquarters.hq_disabled,0)=0",customer)); + var flags=db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ('GLOBAL_CUSTOMER_READONLY_DISABLED','MASK_EMP_CSC_MATRIX_ENABLED','USER_2FA_NO_DEACTIVATION') ORDER BY hq_id,par_key",u.headquartersId()); + for(var r:flags)if(flags.stream().filter(x->id(x,"hq_id")==id(r,"hq_id")&&text(x,"par_key").equals(text(r,"par_key"))).count()>1)throw bad("Die Vorgaben der Kundenmitarbeiterverwaltung sind mehrfach vorhanden."); + String matrix=setting(flags,u.headquartersId(),"MASK_EMP_CSC_MATRIX_ENABLED");if(matrix.isEmpty())matrix=setting(flags,0,"MASK_EMP_CSC_MATRIX_ENABLED"); + boolean present=shared.matrixPresent();String heritage=text(one(db.queryForList("SELECT emp_heritable_rights FROM employee WHERE emp_id=?",u.employeeId())),"emp_heritable_rights"); + return new Context(scope,cs,id(entity,"hq_id"),id(entity,"cs_admin"),scope.can(10)||setting(flags,0,"GLOBAL_CUSTOMER_READONLY_DISABLED").equals("1"),present,matrix.equals("1"),setting(flags,0,"USER_2FA_NO_DEACTIVATION").equals("1"),present?shared.grants(u.employeeId()):List.of(),heritage); + } + static String setting(List> rows,long hq,String key){return rows.stream().filter(r->id(r,"hq_id")==hq&&text(r,"par_key").equals(key)).map(r->text(r,"par_value")).findFirst().orElse("");} + State state(LegacyUser u,Context c,long employee){ + if(employee!=u.employeeId())c.scope().require(2); + var p=one(db.queryForList(PortalEmployees.PERSON+" AND employee.emp_id=?",c.customer().id(),c.headquarters(),employee)); + if(!c.customer().allowed().contains(id(p,"csc_id")))throw CustomerPortalScope.denied(); + if(db.queryForList("SELECT emp_id FROM employee WHERE usr_id=?",id(p,"usr_id")).size()!=1||!db.queryForList("SELECT cr_id FROM courier WHERE usr_id=?",id(p,"usr_id")).isEmpty()||!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",employee).isEmpty())throw bad("Der Benutzer ist nicht eindeutig als Kundenmitarbeiter zugeordnet."); + var roots=db.queryForList("SELECT cs_id FROM customer WHERE cs_admin=?",employee);boolean root=employee==c.root(); + if(!roots.isEmpty()&&(!root||roots.size()!=1))throw bad("Der Mitarbeiter ist einem anderen Kundenhauptzugang zugeordnet."); + return new State(c,p,c.matrix()?shared.grants(employee):List.of(),root,employee==u.employeeId()); + } + static Map profile(Map p){var out=PortalEmployees.profile(p);out.remove("birthdate");return out;} + static boolean canProfile(State s){return s.context().scope().can(2)&&s.context().editable()&&(!s.root()||s.self());} + static boolean canRights(State s){return s.context().scope().can(2)&&!s.root()&&!s.self();} + static boolean canRight(State s,int i){return canRights(s)&&s.context().scope().can(i)&&EmployeeAdministration.bit(s.context().heritage(),i);} + static boolean rootActor(LegacyUser u,Context c){return u.employeeId()==c.root();} + static boolean canPassword(LegacyUser u,State s){return s.context().editable()&&(s.self()||rootActor(u,s.context()));} + static boolean canReset(LegacyUser u,State s){return s.self()||rootActor(u,s.context());} + static boolean canMatrix(State s){var c=s.context();return c.matrix()&&c.matrixEnabled()&&c.scope().can(2)&&c.scope().can(10)&&c.scope().can(12)&&!s.self()&&!s.root();} + static boolean canCenter(LegacyUser u,State s,long center){return canMatrix(s)&¢er!=s.context().customer().root()&&s.context().customer().allowed().contains(center)&&(rootActor(u,s.context())||s.context().actorGrants().contains(center));} + static Object version(State s){var c=s.context();return List.of(s.person(),s.grants(),s.root(),s.self(),c.scope().rights(),c.heritage(),c.root(),c.headquarters(),c.editable(),c.matrix(),c.matrixEnabled(),c.noSelfReset(),c.actorGrants(),c.customer().centers());} + + Map query(LegacyUser u,Query q){return db.readTransaction(()->{ + if(q==null||q.page()<0||q.page()>10000||!SORTS.containsKey(q.sort()))throw bad("Ungültige Mitarbeitersuche.");var c=context(u,q.customer()); + var centers=CustomerPortalScope.centers(c.scope(),q.customer(),q.center(),q.descendants());String search=EmployeeAdministration.optional(q.search(),100,"Suche"); + var args=new ArrayList(List.of(q.customer(),c.headquarters()));args.addAll(new TreeSet<>(centers));String own=c.scope().can(2)?"":" AND employee.emp_id=?";if(!own.isEmpty())args.add(u.employeeId());for(int i=0;i<4;i++)args.add(search);args.add(q.page()*50); + var rows=db.queryForList(PortalEmployees.PERSON+" AND employee.csc_id IN ("+CourierPortal.placeholders(centers.size())+")"+own+" AND (LOCATE(?,user.usr_name)>0 OR LOCATE(?,user.usr_firstname)>0 OR LOCATE(?,user.usr_account)>0 OR LOCATE(?,user.usr_email)>0) ORDER BY "+SORTS.get(q.sort())+(q.descending()?" DESC":" ASC")+",employee.emp_id LIMIT 51 OFFSET ?",args.toArray()); + return fields("items",rows.stream().limit(50).map(p->{var out=profile(p);out.put("root",id(p,"emp_id")==c.root());out.put("centerLabel",c.customer().centers().stream().filter(x->id(x,"id")==id(p,"csc_id")).map(x->text(x,"label")).findFirst().orElse(""));return out;}).toList(),"hasNext",rows.size()>50,"page",q.page(),"canManage",c.scope().can(2),"canCreate",c.scope().can(2)&&c.editable(),"self",u.employeeId(),"centers",c.customer().centers()); + });} + Map view(LegacyUser u,long customer,long employee){return db.readTransaction(()->{ + var s=state(u,context(u,customer),employee);var rights=new ArrayList>();for(int i=0;i<16;i++)rights.add(fields("id",i,"label",PortalEmployees.RIGHTS.get(i),"enabled",EmployeeAdministration.bit(text(s.person(),"emp_rights"),i),"canEdit",canRight(s,i))); + return fields("profile",profile(s.person()),"revision",revision(version(s)),"root",s.root(),"self",s.self(),"canProfile",canProfile(s),"canRights",canRights(s),"canMatrix",canMatrix(s),"canPassword",canPassword(u,s),"canReset",canReset(u,s),"twoFactorActive",id(s.person(),"usr_totp_activated")==1,"canDelete",s.context().scope().can(2)&&!s.root()&&!s.self(),"rights",rights,"centers",s.context().customer().centers().stream().map(r->fields("id",id(r,"id"),"label",text(r,"label"),"selected",s.grants().contains(id(r,"id")),"canEdit",canCenter(u,s,id(r,"id")))).toList()); + });} + void audit(LegacyUser u,long cs,long emp,int op,String detail,String token){shared.audit(u,cs,emp,op,"CUSTOMER_PORTAL | "+detail,token);} + static Object intent(Command c){return fields("kind",c.kind(),"customer",c.customer(),"employee",c.employee(),"center",c.center(),"revision",Objects.toString(c.revision(),""),"profile",c.profile(),"rights",c.rights(),"centers",c.centers(),"account",c.account());} + void authorize(LegacyUser u,State s,String kind){boolean ok=switch(kind){case "PROFILE"->canProfile(s);case "RIGHTS"->canRights(s);case "MATRIX"->canMatrix(s);case "PASSWORD"->canPassword(u,s);case "RESET"->canReset(u,s);case "DELETE"->s.context().scope().can(2)&&!s.root()&&!s.self();default->false;};if(!ok)throw CustomerPortalScope.denied();} + Map save(LegacyUser u,Command r){ + if(r==null||!Set.of("CREATE","PROFILE","RIGHTS","MATRIX","PASSWORD","RESET","DELETE").contains(Objects.toString(r.kind(),"")))throw bad("Ungültige Mitarbeiteränderung.");token(r.requestId()); + support.transactional("user","employee","parameter","employeerights","genericdatacontainer");boolean matrix=shared.matrixPresent();if(matrix)support.transactional("employeecostcenter"); + return db.customerEmployeeWriteTransaction(matrix,()->{ + if(shared.matrixPresent()!=matrix)throw bad("Die Kostenstellenmatrix wurde geändert. Bitte neu laden.");var c=context(u,r.customer()); + if(r.kind().equals("CREATE"))return create(u,c,r); + if(r.kind().equals("DELETE")){c.scope().require(2);Long prior=support.receipt(u,r.requestId(),intent(r));if(prior!=null)return fields("id",prior,"reauthenticate",false);} + var s=state(u,c,r.employee());authorize(u,s,r.kind());long uid=id(s.person(),"usr_id"); + Long prior=support.receipt(u,r.requestId(),intent(r));if(prior!=null){if(r.kind().equals("PASSWORD")&&!shared.portal.accounts.matches(uid,r.password(),false))throw bad("Bereits gespeichert; das Passwort weicht ab oder wurde geändert.");return fields("id",prior,"reauthenticate",s.self()&&(r.kind().equals("PASSWORD")||r.kind().equals("RESET")||r.kind().equals("PROFILE")&&!Objects.equals(r.profile().account(),u.username())));} + unchanged(version(s),r.revision());int operation=74; + switch(r.kind()){ + case "PROFILE"->{var p=PortalEmployees.clean(r.profile());if(p.center()!=id(s.person(),"csc_id")||p.birthdate()!=null)throw bad("Kostenstelle und Geburtstag werden hier nicht geändert.");shared.unique(p.account(),uid);var values=PortalEmployees.values(p);values.remove("usr_birthdate");if(!p.account().equals(text(s.person(),"usr_account")))values.put("usr_totp_sessionkey",PortalAccounts.sessionKey());BusinessSupport.update(db,"user","usr_id",uid,values);} + case "RIGHTS"->{var selected=EmployeeAdministration.distinct(r.rights(),16);if(selected.stream().anyMatch(i->i<0||i>=16))throw bad("Unbekanntes Kundenrecht.");for(int i=0;i<16;i++)if(selected.contains(i)!=EmployeeAdministration.bit(text(s.person(),"emp_rights"),i)&&!canRight(s,i))throw CustomerPortalScope.denied();String next=PortalEmployees.vector(text(s.person(),"emp_rights"),selected);var inheritedRights=new HashSet();for(int i=0;i<16;i++)if(canRight(s,i)?selected.contains(i):EmployeeAdministration.bit(text(s.person(),"emp_heritable_rights"),i))inheritedRights.add(i);String inherited=PortalEmployees.vector(text(s.person(),"emp_heritable_rights"),inheritedRights);db.update("UPDATE employee SET emp_rights=?,emp_heritable_rights=? WHERE emp_id=?",next,inherited,r.employee());operation=115;} + case "MATRIX"->{var selected=EmployeeAdministration.distinct(r.centers(),10000);var visible=c.customer().allowed();if(!visible.containsAll(selected))throw CustomerPortalScope.denied();for(long center:visible){boolean before=s.grants().contains(center),after=selected.contains(center);if(before==after)continue;if(!canCenter(u,s,center))throw CustomerPortalScope.denied();if(after)db.update("INSERT INTO employeecostcenter (emp_id,csc_id) VALUES (?,?)",r.employee(),center);else db.update("DELETE FROM employeecostcenter WHERE emp_id=? AND csc_id=?",r.employee(),center);}operation=115;} + case "PASSWORD"->{EmployeeAccounts.validatePassword(r.password(),r.confirmation());if(!shared.portal.accounts.matches(u.userId(),r.currentPassword(),true))throw bad("Das aktuelle eigene Passwort ist ungültig.");db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_old='',usr_password_modify=NOW(),usr_totp_sessionkey=? WHERE usr_id=?",r.password(),PortalAccounts.sessionKey(),uid);operation=76;} + case "RESET"->{if(!shared.portal.accounts.matches(u.userId(),r.currentPassword(),true))throw bad("Das aktuelle eigene Passwort ist ungültig.");if(!text(s.person(),"usr_account").equals(r.account())||id(s.person(),"usr_totp_activated")!=1)throw bad("Bitte den aktiven Zwei-Faktor-Zugang korrekt bestätigen.");if(s.self()){String secret=db.queryForObject("SELECT usr_totp_secret FROM user WHERE usr_id=?",String.class,uid);if(!LegacyTotp.matches(secret,r.code(),java.time.Instant.now().getEpochSecond()))throw bad("Der aktuelle Zwei-Faktor-Code ist ungültig.");}db.update("UPDATE user SET usr_totp_secret='',usr_totp_activated=0,usr_totp_sessionkey=? WHERE usr_id=?",PortalAccounts.sessionKey(),uid);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid);operation=184;} + case "DELETE"->{if(!text(s.person(),"usr_account").equals(r.account()))throw bad("Bitte den Anmeldenamen zur Löschbestätigung eingeben.");if(matrix)db.update("DELETE FROM employeecostcenter WHERE emp_id=?",r.employee());db.update("DELETE FROM employeerights WHERE emp_id=?",r.employee());db.update("DELETE FROM parameter WHERE emp_id=?",r.employee());db.update("DELETE FROM employee WHERE emp_id=?",r.employee());db.update("DELETE FROM user WHERE usr_id=?",uid);MetaObjectRegistration.enqueueRemoval(db,support,s.context().headquarters(),uid);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid);operation=75;} + } + String detail="ACTION="+r.kind()+" | PROFILE_BEFORE="+support.encode(profile(s.person()));if(r.kind().equals("PROFILE"))detail+=" | PROFILE_AFTER="+support.encode(r.profile());if(r.kind().equals("RIGHTS"))detail+=" | RIGHTS_BEFORE="+text(s.person(),"emp_rights")+" | RIGHTS_AFTER="+r.rights();if(r.kind().equals("MATRIX"))detail+=" | CENTERS_BEFORE="+s.grants()+" | CENTERS_AFTER="+r.centers();audit(u,r.customer(),r.employee(),operation,detail,r.requestId());support.receipt(u,r.requestId(),intent(r),r.employee());return fields("id",r.employee(),"reauthenticate",s.self()&&(r.kind().equals("PASSWORD")||r.kind().equals("RESET")||r.kind().equals("PROFILE")&&!text(s.person(),"usr_account").equals(r.profile().account()))); + }); + } + Map create(LegacyUser u,Context c,Command r){ + c.scope().require(2);if(!c.editable()||!c.customer().allowed().contains(r.center()))throw CustomerPortalScope.denied(); + var p=PortalEmployees.clean(r.profile());if(p.center()!=r.center()||p.birthdate()!=null)throw bad("Ungültige neue Mitarbeiterzuordnung.");EmployeeAccounts.validatePassword(r.password(),r.confirmation());Long prior=support.receipt(u,r.requestId(),intent(r)); + if(prior!=null){var s=state(u,c,prior);if(!shared.portal.accounts.matches(id(s.person(),"usr_id"),r.password(),false))throw bad("Bereits gespeichert; das Passwort weicht ab oder wurde geändert.");return fields("id",prior,"reauthenticate",false);} + shared.unique(p.account(),0);var values=PortalEmployees.values(p);values.putAll(fields("hq_id",c.headquarters(),"usr_type",2,"usr_country","","usr_inv_email","","usr_reminder_email","","usr_password_old","","usr_totp_secret","","usr_totp_sessionkey","","usr_totp_activated",0));long uid=db.insert("user",values);db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_modify=NOW() WHERE usr_id=?",r.password(),uid); + long emp=db.insert("employee",fields("usr_id",uid,"csc_id",r.center(),"emp_rights","0".repeat(70),"emp_heritable_rights","0".repeat(70),"emp_headquarters",Long.toString(c.headquarters())));long md=mandator(db,u);EmployeeAccounts.DEFAULTS.forEach((k,v)->db.insert("parameter",fields("par_key",k,"par_value",v,"emp_id",emp,"hq_id",c.headquarters(),"md_id",md))); + MetaObjectRegistration.enqueue(db,support,c.headquarters(),"usr",uid); + audit(u,r.customer(),emp,73,"PROFILE_CREATED="+support.encode(p),r.requestId());support.receipt(u,r.requestId(),intent(r),emp);return fields("id",emp,"reauthenticate",false); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntryAcceptance.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntryAcceptance.java new file mode 100644 index 0000000..53abb55 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntryAcceptance.java @@ -0,0 +1,24 @@ +package de.stadtbote.votian; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** jobs2 stores the selected acceptance clock time on the current Berlin date. */ +final class CustomerEntryAcceptance { + static LocalDateTime resolve(String time, boolean enabled, Clock clock) { + if(time==null || time.isBlank())return null; + if(!enabled)throw invalid("Die Annahmezeit ist für diese Niederlassung nicht freigegeben."); + if(!time.matches("(?:[01][0-9]|2[0-3]):[0-5][0-9]"))throw invalid("Bitte die Annahmezeit im Format HH:mm angeben."); + return LocalDate.now(clock.withZone(ZoneId.of("Europe/Berlin"))).atTime(LocalTime.parse(time)); + } + static OrderEntryModels.Quote quote(OrderEntryModels.Request request,OrderEntryModels.Quote quote) { + return request.acceptUntil()==null?quote:quote.withMediationDeadline(quote.mediationDeadline(),LegacyOrderPricing.hash(quote.fingerprint()+"|customer-acceptance|"+request.acceptUntil())); + } + static String load(Map job,boolean enabled) { + var at=dateTime(job,"jb_waittime"); + return enabled&&at!=null?at.format(DateTimeFormatter.ofPattern("HH:mm")):null; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntryAccess.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntryAccess.java new file mode 100644 index 0000000..63ddd31 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntryAccess.java @@ -0,0 +1,97 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** jobs2: the caller remains a type-2 customer throughout pricing and persistence. */ +final class CustomerEntryAccess { + static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules(); + final DatabaseService db;final OrderEntryCatalog catalog;final java.time.Clock clock; + CustomerEntryAccess(DatabaseService db){this(db,java.time.Clock.system(java.time.ZoneId.of("Europe/Berlin")));} + CustomerEntryAccess(DatabaseService db,java.time.Clock clock){this.db=db;this.clock=clock;catalog=new OrderEntryCatalog(db);} + record State(long customer,long base,long payer,Set centers,Map customerRow,List> centerRows,List> vehicles,List> filters,boolean payerSelectable,boolean remarks,boolean countryLocked,boolean dimensions,boolean showPrice,String orderer,String revision,List> vehicleTypes,int defaultVehicleType,boolean dimensionsRequired,boolean cashPayerSelect,long cashPayer,boolean acceptanceEnabled){} + State state(LegacyUser u){ + var c=CustomerPortalScope.context(db,u);long customer=id(c.account(),"cs_id"),base=id(c.account(),"csc_id"); + var cs=OrderEntryCatalog.one(db.queryForList("SELECT cs.cs_id,cs.cs_jbedit,cs.cs_jbedit_cr,cs.cs_filter_jbedit,cs.cs_ar,cs.cs_invmode,cs.cs_jb_commission_no,cs.cs_jobbatch,h.hq_workmode,h.hq_vht_default FROM customer cs JOIN headquarters h ON h.hq_id=cs.hq_id WHERE cs.cs_id=? AND cs.hq_id=?",customer,u.headquartersId()),"Die Kundenerfassung ist nicht verfügbar."); + if(id(cs,"hq_workmode")!=0&&id(cs,"cs_jbedit")==0)throw CustomerPortalScope.denied(); + var own=CustomerPortalScope.customer(c,customer);var allowed=new TreeSet();var centers=new ArrayList>(); + var args=new ArrayList();args.add(u.headquartersId());args.addAll(new TreeSet<>(own.allowed())); + var active=db.queryForList(OrderEntryCatalog.COST_CENTER_SELECT+OrderEntryCatalog.COST_CENTER_FROM+" AND cc.csc_id IN ("+CourierPortal.placeholders(own.allowed().size())+") ORDER BY cc.csc_name,cc.csc_id",args.toArray()); + for(var row:active){long center=id(row,"csc_id");if(!allowed.add(center))throw invalid("Mehrdeutige Kostenstelle in der Kundenerfassung.");centers.add(fields("id",center,"label",text(row,"csc_name"),"external",id(row,"csc_is_extern")==1));} + if(!allowed.contains(base))throw CustomerPortalScope.denied(); + boolean select=unscoped("MASK_CUST_SET_PAYER_"+customer).equals("1"); + // jobs2/job_options.php initializes the customer entry with csc_id_orderer. + // The master-data third payer is an internal-entry default, not a portal access grant. + long payer=base; + catalog.costCenter(u,payer,true); + Set excluded=new HashSet<>(CustomerPortal.parts(catalog.parameter(u.headquartersId(),"MASK_EXCLUDE_VHT_IDS_CS"))); + excluded.remove(""); + if(excluded.stream().anyMatch(v->!v.matches("[0-9]+")))throw invalid("Ungültige Fahrzeugtyp-Sperre in der Kundenerfassung."); + var types=db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' AND mt_sort>0 ORDER BY mt_sort").stream().filter(v->!excluded.contains(Long.toString(id(v,"id")))).toList(); + if(types.isEmpty()||types.stream().map(v->id(v,"id")).distinct().count()!=types.size())throw invalid("Keine eindeutigen Fahrzeugtypen für die Kundenerfassung verfügbar."); + int defaultType=(int)(types.stream().anyMatch(v->id(v,"id")==id(cs,"hq_vht_default"))?id(cs,"hq_vht_default"):id(types.getFirst(),"id")); + var vehicles=id(cs,"cs_jbedit_cr")==1?db.queryForList("SELECT DISTINCT cv.crvh_id AS id,cv.crvh_sid AS label,cv.vht_id AS type "+OrderEntryCatalog.VEHICLE_FROM+" AND cv.crvh_sid=cr.cr_sid AND EXISTS (SELECT 1 FROM customercourier rel WHERE rel.cs_id=? AND rel.cr_id=cr.cr_id AND rel.cscr_relation=1) ORDER BY cv.crvh_sid,cv.crvh_id",u.headquartersId(),customer).stream().filter(v->types.stream().anyMatch(t->id(t,"id")==id(v,"type"))).toList():List.>of(); + Set filterCodes=new HashSet<>(CustomerPortal.parts(text(cs,"cs_filter_jbedit"))); + var filters=db.queryForList("SELECT crf_short AS code,crf_longtext AS label FROM courierfilter ORDER BY crf_short").stream().filter(r->filterCodes.contains(text(r,"code"))).toList(); + boolean remarks=catalog.enabled(u.headquartersId(),"MASK_CUSTOMER_SHOW_REMARK"),locked=unscoped("MASK_CUST_AD_COUNTRY_DISABLED_"+customer).equals("1"),dimensions=CustomerPortal.parts(catalog.parameter(u.headquartersId(),"RANKING_JB2CRVH_MEASURE_CS_IDS")).contains(Long.toString(customer)); + boolean required=unscoped("RANKING_JB2CRVH_MEASURE_CS_MANDATORY_"+customer).equals("1");dimensions|=required; + boolean showPrice=catalog.enabled(u.headquartersId(),"MASK_CUSTOMER_SHOW_PRICEQUERY")&&id(cs,"cs_invmode")!=0&&!unscoped("MASK_CS_DONT_SHOW_STANDARD_PRICE_"+u.userId()).equals("1")&&!catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+customer); + String orderer=(text(c.account(),"usr_firstname")+" "+text(c.account(),"usr_name")).strip();if(orderer.isBlank())orderer=u.username();if(orderer.length()>50)orderer=orderer.substring(0,50); + boolean cashSelect=catalog.enabled(u.headquartersId(),"MASK_CASH_PAYER_SELECT");String cashConfig=catalog.parameter(u.headquartersId(),"CSC_ID_PAYER_CASH");long cashPayer=0;try{cashPayer=Long.parseLong(cashConfig);}catch(NumberFormatException ignored){}if(cashPayer<0)cashPayer=0; + var revisionParts=new ArrayList(List.of(u.userId(),u.employeeId(),u.headquartersId(),customer,base,payer,c.rights(),cs,centers,vehicles,filters,select,remarks,locked,dimensions,showPrice,orderer,selfDiscount(u,customer),types,defaultType,required,cashSelect,cashConfig)); + var standardPricing=CustomerStandardPricing.read(db,catalog,u,customer);if(standardPricing.disabled())revisionParts.add(standardPricing); + var photoBasis=new CustomerEntryPhotos(db).revisionBasis(customer,cashPayer);if(!photoBasis.isEmpty())revisionParts.add(photoBasis); + var externalPolicy=new OrderExternalStations(db).policy(u);if(!externalPolicy.mode().isEmpty()||!externalPolicy.configured().isEmpty())revisionParts.add(externalPolicy); + boolean acceptanceEnabled=catalog.enabled(u.headquartersId(),"MASK_WAITTIME_ENABLED");if(acceptanceEnabled)revisionParts.add("CUSTOMER_ACCEPTANCE_ENABLED"); + String revision=LegacyOrderPricing.hash(OrderEntryExtras.json(revisionParts)); + return new State(customer,base,payer,Set.copyOf(allowed),cs,List.copyOf(centers),vehicles,filters,select,remarks,locked,dimensions,showPrice,orderer,revision,types,defaultType,required,cashSelect,cashPayer,acceptanceEnabled); + } + // These legacy customer-specific switches are looked up without HQ scope. Ambiguity is an error. + String unscoped(String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND par_key=?",key);var values=rows.stream().map(r->text(r,"par_value")).distinct().toList();if(values.size()>1)throw invalid("Mehrdeutige Kundeneinstellung: "+key);return values.isEmpty()?"":values.getFirst();} + BigDecimal selfDiscount(LegacyUser u,long customer){if(!catalog.enabled(0,"CUSTOMER_MASK_JB_CREATOR_DISCOUNT_"+customer))return BigDecimal.ZERO;String raw=catalog.parameter(u.headquartersId(),"MASK_CS_SELF_SERVICE_DISCOUNT");if(raw.isBlank())return BigDecimal.ZERO;try{var rate=new BigDecimal(raw.replace(',','.'));if(rate.signum()<0||rate.compareTo(new BigDecimal("100"))>0)throw new NumberFormatException();return rate;}catch(NumberFormatException e){throw invalid("Der Selbstbedienungsrabatt ist ungültig.");}} + Request command(LegacyUser u,CustomerOrderEntry.Draft d){ + State s=state(u);if(d==null||!s.revision().equals(d.revision()))throw OrderEntryService.conflict("Die Vorgaben für die Kundenerfassung haben sich geändert. Bitte neu laden."); + if(d.mode()!=Mode.IMMEDIATE&&d.mode()!=Mode.RESERVATION)throw invalid("Im Kundenzugang sind Sofortauftrag und Vorbestellung verfügbar."); + if(d.stops()==null||d.stops().isEmpty()||d.stops().size()>100)throw invalid("Bitte 1 bis 100 Stationen angeben."); + if(d.payerCostCenterId()!=s.payer()&&(!s.payerSelectable()||!s.centers().contains(d.payerCostCenterId())))throw CustomerPortalScope.denied(); + if((d.payment()==Payment.CASH||d.costSplit())&&!s.payerSelectable())throw CustomerPortalScope.denied(); + if(d.payment()==Payment.CASH&&s.cashPayer()<=0)throw invalid("Für die Niederlassung ist kein Systembarzahler konfiguriert."); + if(d.costSplit()&&(d.payment()!=Payment.INVOICE||s.cashPayerSelect()))throw invalid("Kostenteilung ist in diesem Zahlungsmodus nicht verfügbar."); + catalog.costCenter(u,d.payerCostCenterId(),d.payment()!=Payment.CASH); + if(s.vehicleTypes().stream().noneMatch(v->id(v,"id")==d.vehicleTypeId()))throw invalid("Dieser Fahrzeugtyp ist für den Kundenzugang nicht freigegeben."); + if(d.courierVehicleId()!=null&&s.vehicles().stream().noneMatch(v->id(v,"id")==d.courierVehicleId()))throw CustomerPortalScope.denied(); + if(d.courierVehicleId()!=null&&s.vehicles().stream().anyMatch(v->id(v,"id")==d.courierVehicleId()&&id(v,"type")!=d.vehicleTypeId()))throw invalid("Der Fahrzeugtyp passt nicht zum gewünschten Kurier."); + if(d.filters()==null||d.filters().size()>50||!s.filters().stream().map(r->text(r,"code")).toList().containsAll(d.filters()))throw CustomerPortalScope.denied(); + if(!s.dimensions()&&List.of(nz(d.weight()),nz(d.length()),nz(d.width()),nz(d.height()),nz(d.positions())).stream().anyMatch(n->n.signum()!=0))throw invalid("Transportmaße sind für diesen Kunden nicht freigegeben."); + if(s.dimensionsRequired()&&List.of(nz(d.weight()),nz(d.length()),nz(d.width()),nz(d.height())).stream().anyMatch(n->n.signum()<=0))throw invalid("Bitte die Lademaße (Gewicht, Länge, Breite, Höhe) vollständig angeben."); + var stops=new ArrayList(); + for(var stop:d.stops()){ + if(stop==null)throw invalid("Eine Station fehlt."); + if(stop.costCenterId()!=null&&!s.centers().contains(stop.costCenterId()))throw CustomerPortalScope.denied(); + if(s.countryLocked()&&!"DE".equals(stop.country()))throw invalid("Die Länderauswahl ist für diesen Kunden gesperrt."); + if(!s.remarks()&&stop.note()!=null&&!stop.note().isBlank())throw invalid("Stationsbemerkungen sind für diesen Kunden nicht freigegeben."); + if(stop.billingPostalId()!=null||!stop.attachments().isEmpty()||stop.photoWorkflow()!=null)throw invalid("Diese zusätzlichen Stationsfelder sind in der Kundenerfassung nicht freigegeben."); + stops.add(stop); + } + new CustomerEntryTracking(db).validate(s,d); + var tree=JSON.valueToTree(fields("mode",d.mode(),"pickupAt",d.pickupAt(),"acceptUntil",CustomerEntryAcceptance.resolve(d.acceptUntilTime(),s.acceptanceEnabled(),clock),"payerCostCenterId",d.payerCostCenterId(),"payment",d.payment(),"cashStop",d.cashStop(),"costSplit",d.costSplit(),"vehicleTypeId",d.vehicleTypeId(),"courierVehicleId",d.courierVehicleId(),"stops",stops,"orderer",s.orderer(),"reference",d.reference(),"notes",d.notes(),"weight",d.weight(),"length",d.length(),"width",d.width(),"height",d.height(),"positions",d.positions(),"manualDispatch",id(s.customerRow(),"cs_ar")==0,"incomplete",catalog.enabled(0,"MASK_CUSTOMER_INCOMPLETE_"+s.customer()),"pricing",new CustomerEntryTours(db).pricing(u,s,d),"sourceOrderId",d.sourceOrderId(),"quoteFingerprint",d.quoteFingerprint(),"requestId",d.requestId(),"origin",4,"courierFilters",d.filters(),"acceptUnverifiedAddresses",false)); + try{return JSON.treeToValue(tree,Request.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw invalid("Ungültige Kundeneingabe.");} + } + void verify(LegacyUser u,CustomerOrderEntry.Draft draft,Request expected){if(!command(u,draft).equals(expected))throw OrderEntryService.conflict("Die Kundenvorgaben wurden geändert. Bitte erneut prüfen.");} + void lock(LegacyUser u,CustomerOrderEntry.Draft draft){ + db.lockRows("SELECT emp_id FROM employee WHERE emp_id=? AND usr_id=?",u.employeeId(),u.userId()); + db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()); + var account=CustomerPortalScope.verify(db,u);db.lockRows("SELECT cs_id FROM customer WHERE cs_id=?",id(account,"cs_id")); + var ids=new TreeSet();ids.add(id(account,"csc_id"));ids.add(draft.payerCostCenterId());for(var stop:draft.stops())if(stop.costCenterId()!=null)ids.add(stop.costCenterId()); + if(draft.payment()==Payment.CASH){long cash=state(u).cashPayer();if(cash>0)ids.add(cash);} + if(draft.sourceOrderId()!=null)db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",draft.sourceOrderId(),u.headquartersId()); + for(long center:ids)db.lockRows("SELECT csc_id FROM costcenter WHERE csc_id=?",center); + } + static BigDecimal nz(BigDecimal n){return n==null?BigDecimal.ZERO:n;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntryPhotos.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntryPhotos.java new file mode 100644 index 0000000..7d9b502 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntryPhotos.java @@ -0,0 +1,36 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Classic jobs2 photo quantities; independent of the external workflow service. */ +final class CustomerEntryPhotos { + record Requirement(int station,int minimum,int maximum){} + final DatabaseService db; + CustomerEntryPhotos(DatabaseService db){this.db=db;} + List> settings(long customer){return db.queryForList("SELECT par_id,par_key,md_id,hq_id,emp_id,par_value FROM parameter WHERE par_key IN (?,?) ORDER BY par_key,par_id",ExtrasCustomerSettings.photoKey(customer),ExtrasCustomerSettings.rangeKey(customer));} + long cashCustomer(long payer){if(payer<=0)return 0;var rows=db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",payer);return rows.size()==1?id(rows.getFirst(),"cs_id"):0;} + List revisionBasis(long customer,long cashPayer){var basis=new ArrayList();var own=settings(customer);if(!own.isEmpty())basis.add(own);long cash=cashCustomer(cashPayer);if(cash>0&&cash!=customer){var rows=settings(cash);if(!rows.isEmpty()){basis.add(cash);basis.add(rows);}}return basis;} + ExtrasCustomerSettings.Values config(long customer){var rows=settings(customer).stream().map(r->{var next=new LinkedHashMap<>(r);if(text(r,"par_key").equals(ExtrasCustomerSettings.rangeKey(customer))&&text(r,"par_value").isBlank())next.put("par_value","1|1");return (Map)next;}).toList();return ExtrasCustomerSettings.decode(ExtrasCustomerSettings.Type.PHOTO,customer,rows);} + List plan(CustomerEntryAccess.State state,CustomerOrderEntry.Draft draft){ + var own=config(state.customer());boolean systemCash=draft.payment()==OrderEntryModels.Payment.CASH&&!state.cashPayerSelect(); + var initial=systemCash?config(cashCustomer(state.cashPayer())):own; + return plan(draft.stops().size(),initial,own,systemCash?draft.cashStop():null); + } + static List plan(int stations,ExtrasCustomerSettings.Values initial,ExtrasCustomerSettings.Values own,Integer cashStop){ + var result=new ArrayList(); + for(int i=1;i=cashStop?own:initial;if(config.mode()!=null&&(config.mode()==2||config.mode()==3))result.add(new Requirement(i+1,config.minimum(),config.maximum()));} + return List.copyOf(result); + } + void created(long job,List plan){ + if(plan.isEmpty())return; + var stops=db.queryForList("SELECT tr_id,tr_sort FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job); + for(var r:plan){var matching=stops.stream().filter(t->id(t,"tr_sort")==r.station()).toList();if(matching.size()!=1)throw bad("Die Fotoanforderung kann keiner eindeutigen Station zugeordnet werden.");long station=id(matching.getFirst(),"tr_id"); + if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_photo'",station).isEmpty())throw bad("Die Station besitzt bereits eine Fotoanforderung."); + db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",station,"gdc_gen_fieldname","tr_photo","gdc_content",r.minimum()+"|"+r.maximum(),"gdc_context",Long.toString(job))); + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java new file mode 100644 index 0000000..ba22403 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntrySettings.java @@ -0,0 +1,109 @@ +package de.stadtbote.votian; + +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; + +/** Customer entry controls from the current server's customer_special.php. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/entry-settings") +class CustomerEntrySettings { + static final Map PARAMETERS; + static { + var p=new LinkedHashMap(); + p.put("stationNumbers","JOBDETAILS_STATIONS_STATION_NUMBER_"); + p.put("creatorDiscount","CUSTOMER_MASK_JB_CREATOR_DISCOUNT_"); + p.put("cancellation","CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_"); + p.put("favoriteDispatch","CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"); + p.put("noStandardPrice","MASK_CS_DONT_MAKE_STANDARD_PRICE_"); + p.put("calculatorOnly","CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"); + p.put("unverifiedAddresses","MASK_CUST_ALL_ADDR_ENABLED_"); + p.put("cargoRequired","RANKING_JB2CRVH_MEASURE_CS_MANDATORY_"); + p.put("incomplete","CUSTOMER_MASK_JB_INCOMPLETE_"); + p.put("missingReferenceIncomplete","CS_MASK_COMM_NO_B_"); + p.put("referenceEveryStation","CS_MASK_COMM_NO_C_"); + PARAMETERS=Collections.unmodifiableMap(p); + } + static final String MEASURES="RANKING_JB2CRVH_MEASURE_CS_IDS",COLOR="JOBLIST_CS_JB_COLOR_"; + static final Set FLAGS; + static {var keys=new HashSet<>(PARAMETERS.keySet());keys.addAll(List.of("entryEnabled","automaticDispatch","cargo","stationReference"));FLAGS=Set.copyOf(keys);} + record Values(Map flags,String color){} + record Draft(String revision,Values values){} + record Command(String requestId,Draft draft,String fingerprint){} + record View(long customer,String label,String revision,Values values,boolean canManageFavoriteDispatch,boolean jobsHidden){} + record Preview(String fingerprint,Values before,Values after){} + record State(Map owner,List> parameters,View view){} + final DatabaseService db;final BusinessSupport support; + CustomerEntrySettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping View get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);} + @PostMapping("/preview") Preview check(Authentication a,@PathVariable long customer,@RequestBody Draft d){return preview(ApiController.user(a),customer,d);} + @PostMapping Map post(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + LegacyUser access(LegacyUser actor){actor.require(0);actor.require(2);var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);u.require(2);return u;} + State state(LegacyUser u,long customer){ + if(customer<=0)throw bad("Ungültiger Kunde."); + var owner=one(db.queryForList("SELECT cs_id,cs_eid,customer.hq_id,customer.cmp_id,cmp_comp,cmp_comp2,cs_jbedit,cs_ar,cs_joblist_disabled,cs_commission_no FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE cs_id=? AND customer.hq_id=?",customer,u.headquartersId())); + var names=new ArrayList();for(String p:PARAMETERS.values())names.add(p+customer);names.add(COLOR+customer);names.add(MEASURES); + var all=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(names.size())+") ORDER BY par_key,hq_id,par_id",names.toArray()); + var rows=new ArrayList>();var byKey=new LinkedHashMap(); + for(var r:all){String key=text(r,"par_key");long hq=id(r,"hq_id"); + if(key.equals(MEASURES)&&hq!=u.headquartersId())continue; + if(id(r,"md_id")!=0||id(r,"emp_id")!=0||!key.equals(MEASURES)&&hq!=0)throw bad("Abweichender Geltungsbereich der Kundenerfassungsvorgaben."); + if(byKey.put(key,text(r,"par_value"))!=null)throw bad("Mehrdeutige Kundenerfassungsvorgaben.");rows.add(r); + } + var flags=new LinkedHashMap();flags.put("entryEnabled",id(owner,"cs_jbedit")==1);flags.put("automaticDispatch",id(owner,"cs_ar")==1); + for(var e:PARAMETERS.entrySet())flags.put(e.getKey(),CustomerMailSettings.binary(byKey.get(e.getValue()+customer))==1); + flags.put("stationReference",id(owner,"cs_commission_no")==1); + if(!flags.get("stationReference"))flags.put("missingReferenceIncomplete",false); + if(!flags.get("missingReferenceIncomplete"))flags.put("referenceEveryStation",false); + flags.put("cargo",measureIds(byKey.get(MEASURES)).contains(customer)); + String color=byKey.getOrDefault(COLOR+customer,"FFFFFF");if(color.isBlank())color="FFFFFF";color=normalizeColor(color); + var values=new Values(Collections.unmodifiableMap(flags),color); + var view=new View(customer,String.join(" · ",List.of(text(owner,"cs_eid"),text(owner,"cmp_comp"),text(owner,"cmp_comp2")).stream().filter(s->!s.isBlank()).toList()),revision(List.of(owner,rows)),values,u.hasRight(41),id(owner,"cs_joblist_disabled")==1); + return new State(owner,List.copyOf(rows),view); + } + static LinkedHashSet measureIds(String raw){var result=new LinkedHashSet();if(raw==null||raw.isBlank())return result;for(String token:raw.split(",",-1)){if(token.isBlank())continue;if(!token.strip().matches("[0-9]{1,18}"))throw bad("Die Kundenliste für Lademaße ist ungültig.");long id=Long.parseLong(token.strip());if(id<=0)throw bad("Die Kundenliste für Lademaße ist ungültig.");result.add(id);}return result;} + static String normalizeColor(String color){if(color==null||!color.matches("[A-Fa-f0-9]{6}"))throw bad("Bitte einen sechsstelligen RGB-Farbwert angeben.");return color.toUpperCase(Locale.ROOT);} + static Values normalize(Values v){if(v==null||v.flags()==null||!v.flags().keySet().equals(FLAGS)||v.flags().values().stream().anyMatch(Objects::isNull))throw bad("Kundenerfassungsvorgaben fehlen oder enthalten unbekannte Felder.");if(v.flags().get("cargoRequired")&&!v.flags().get("cargo"))throw bad("Pflicht-Lademaße benötigen aktivierte Lademaße.");if((v.flags().get("missingReferenceIncomplete")&&!v.flags().get("stationReference"))||(v.flags().get("referenceEveryStation")&&!v.flags().get("missingReferenceIncomplete")))throw bad("Kommissionsnummern an jeder Station benötigen die Unfertig-Prüfung und aktivierte Stationskommissionsnummern.");return new Values(Map.copyOf(v.flags()),normalizeColor(v.color()));} + static void validate(Draft d){if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Bestandsrevision fehlt.");normalize(d.values());} + View view(LegacyUser actor,long customer){actor.require(0);actor.require(2);return db.readTransaction(()->state(access(actor),customer).view());} + Preview prepare(LegacyUser u,long customer,Draft d,State s){ + if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Kundenerfassungsvorgaben wurden inzwischen geändert. Bitte neu laden."); + var v=normalize(d.values());if(!u.hasRight(41)&&!v.flags().get("favoriteDispatch").equals(s.view().values().flags().get("favoriteDispatch")))throw new org.springframework.security.access.AccessDeniedException("Recht 41 für die Vermittlung an bevorzugte Transporteure fehlt."); + return new Preview(revision(List.of(u.headquartersId(),customer,d,s.view().revision())),s.view().values(),v); + } + Preview preview(LegacyUser actor,long customer,Draft d){actor.require(0);actor.require(2);validate(d);return db.readTransaction(()->{var u=access(actor);return prepare(u,customer,d,state(u,customer));});} + Map save(LegacyUser actor,long customer,Command c){ + actor.require(0);actor.require(2);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen.");access(actor); + return db.customerMailSettingsWriteTransaction(()->{ + var u=access(actor);var s=state(u,customer);var receipt=List.of("CUSTOMER_ENTRY_SETTINGS",customer,c.draft(),c.fingerprint()); + var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true); + var p=prepare(u,customer,c.draft(),s);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Änderungsvorschau passt nicht zu diesen Angaben."); + var v=p.after();boolean changed=!p.before().equals(v); + if(changed){ + 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()));} + 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"); + if(old==null){if(!defaultValue)db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",key.equals(MEASURES)?u.headquartersId():0,"emp_id",0,"par_value",value,"par_text",""));} + else if(!value.equals(text(old,"par_value")))db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id")); + } + support.audit(u,0,customer,0,203,"Kundenerfassungsvorgaben geändert",c.requestId()); + } + support.receipt(u,c.requestId(),receipt,changed?1:0);return fields("changed",changed?1:0,"repeated",false); + }); + } + static void clearStationReferences(DatabaseService db,BusinessSupport support,long customer){ + support.transactional("parameter"); + var rows=db.lockRows("SELECT * FROM parameter WHERE par_key IN (?,?) ORDER BY par_key,par_id",PARAMETERS.get("missingReferenceIncomplete")+customer,PARAMETERS.get("referenceEveryStation")+customer); + var seen=new HashSet();for(var r:rows){if(id(r,"md_id")!=0||id(r,"hq_id")!=0||id(r,"emp_id")!=0||!seen.add(text(r,"par_key")))throw bad("Die Kommissionsnummer-Vorgaben sind mehrdeutig. Bitte den Bestand prüfen.");} + for(var r:rows)if(!text(r,"par_value").equals("0"))db.update("UPDATE parameter SET par_value='0' WHERE par_id=?",id(r,"par_id")); + } + +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java new file mode 100644 index 0000000..092c307 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntryTours.java @@ -0,0 +1,83 @@ +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.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** Read-only customer projections of get_tournames/get_saved_tour. No PHP edit lock is set. */ +@RestController +@RequestMapping("/api/customer-portal/entry/tours") +class CustomerEntryTours { + final DatabaseService db;final CustomerEntryAccess access; + CustomerEntryTours(DatabaseService db){this.db=db;access=new CustomerEntryAccess(db);} + static final String PAYER="CASE WHEN j.jb_payment IN (1,2) AND COALESCE(j.csc_id_payer_cash,0)=0 AND (j.jb_payment=2 OR COALESCE(j.csc_id_payer,0)=0) THEN j.csc_id ELSE IF(j.csc_id_payer_cash>0,j.csc_id_payer_cash,j.csc_id_payer) END"; + record Source(Map job,List stops,List> prices,String revision,boolean zone,BigDecimal courierService){} + @GetMapping Object search(Authentication a,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="true") boolean templates,@RequestParam(required=false) Long payer,@RequestParam(defaultValue="0") int page){return search(ApiController.user(a),q,templates,payer,page);} + @GetMapping("/{id}") Object load(Authentication a,@PathVariable long id){return load(ApiController.user(a),id);} + Map search(LegacyUser u,String q,boolean templates,Long payer,int page){return db.readTransaction(()->{ + var s=access.state(u);if(!templates)CustomerPortalScope.context(db,u).require(4); + if(q==null||q.length()>100||page<0||page>10000)throw invalid("Ungültige Tourensuche."); + var centers=new TreeSet<>(s.centers());if(payer!=null)centers.retainAll(Set.of(payer)); + if(centers.isEmpty())return fields("rows",List.of(),"page",page,"hasNext",false); + var args=new ArrayList();args.add(u.headquartersId());args.addAll(centers);String pattern=OrderEntryCatalog.search(q);args.add(pattern);args.add(pattern);args.add(pattern);args.add(page*50); + var rows=db.queryForList("SELECT j.jb_id AS id,j.jb_tourname AS name,j.jb_commission_no AS reference,j.jb_ordertime AS ordered FROM job j WHERE j.hq_id=? AND ("+PAYER+") IN ("+CourierPortal.placeholders(centers.size())+")"+(templates?" AND LENGTH(TRIM(j.jb_tourname))>0":"")+" AND (CAST(j.jb_id AS CHAR) LIKE ? OR j.jb_tourname LIKE ? OR j.jb_commission_no LIKE ?) ORDER BY "+(templates?"j.jb_tourname,":"")+"j.jb_id DESC LIMIT 51 OFFSET ?",args.toArray()); + return fields("rows",rows.stream().limit(50).map(r->fields("id",id(r,"id"),"name",text(r,"name"),"reference",text(r,"reference"),"ordered",dateTime(r,"ordered"))).toList(),"page",page,"hasNext",rows.size()>50); + });} + Map require(LegacyUser u,CustomerEntryAccess.State s,long order){ + var rows=db.queryForList("SELECT jb_id,hq_id,csc_id,csc_id_payer,csc_id_payer_cash,jb_payment+0 AS jb_payment,jb_tourname,jb_waittime,jb_commission_no,jb_freetext_1,vht_id,jb_weight,jb_crvh_length,jb_crvh_width,jb_crvh_height,jb_crvh_position,jb_cr_filter,jb_cr_filter_opt,cr_sid,jb_fixprice,jb_serviceprice,jb_cr_subprice,jb_sales_tax_rate_sign FROM job WHERE jb_id=? AND hq_id=?",order,u.headquartersId()); + if(rows.size()!=1)throw CustomerPortalScope.denied();var j=rows.getFirst();long payer=payer(j); + if(!s.centers().contains(payer))throw CustomerPortalScope.denied(); + if(text(j,"jb_tourname").isBlank())CustomerPortalScope.context(db,u).require(4); + return j; + } + Source source(LegacyUser u,CustomerEntryAccess.State s,long order){ + var j=require(u,s,order); + var rows=db.queryForList("SELECT t.tr_sort,t.csc_id,t.tr_comp,t.tr_person,t.tr_phone,t.tr_remark,t.tr_commission_no,t.tr_hsno,t.tr_ware_from_to,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",order); + if(rows.isEmpty()||rows.size()>100||rows.stream().map(r->id(r,"tr_sort")).distinct().count()!=rows.size())throw invalid("Die gespeicherte Tour hat keine eindeutige Stationsfolge mit 1 bis 100 Stationen."); + var stops=rows.stream().map(r->new Station(s.centers().contains(id(r,"csc_id"))?id(r,"csc_id"):null,switch((int)id(r,"tr_ware_from_to")){case 0->Models.StopType.NONE;case 1->Models.StopType.DELIVERY;case 2->Models.StopType.PICKUP;case 3->Models.StopType.BOTH;default->throw invalid("Ungültige Warenbewegung in der gespeicherten Tour.");},text(r,"tr_comp"),text(r,"ad_street"),text(r,"tr_hsno"),text(r,"ad_zipcode"),text(r,"ad_city"),text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),text(r,"tr_person"),text(r,"tr_phone"),s.remarks()?copyRemark(text(r,"tr_remark")):"",text(r,"tr_commission_no"),null,null,List.of())).toList(); + var prices=db.queryForList("SELECT trs_srv_name,trs_srvt_name,trs_discount FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",order); + var meta=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cr_serviceprice'",order); + if(meta.size()>1)throw invalid("Mehrdeutige Servicevergütung in der gespeicherten Tour."); + BigDecimal cr=meta.isEmpty()?BigDecimal.ZERO:amount(text(meta.getFirst(),"gdc_content")); + var basis=new ArrayList(List.of(j,rows,prices,cr));var tracking=new CustomerEntryTracking(db);if(tracking.enabled(s.customer()))basis.add(tracking.load(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",order).stream().map(t->id(t,"tr_id")).toList())); + return new Source(j,stops,prices,LegacyOrderPricing.hash(OrderEntryExtras.json(basis)),prices.stream().anyMatch(r->text(r,"trs_srvt_name").equals("z")),cr); + } + Map load(LegacyUser u,long order){return db.readTransaction(()->{ + var s=access.state(u);var source=source(u,s,order);var j=source.job(); + boolean cash=id(j,"jb_payment")==2,split=id(j,"jb_payment")==1; + if((cash||split)&&!s.payerSelectable())throw CustomerPortalScope.denied(); + if(cash&&s.cashPayer()<=0||split&&s.cashPayerSelect())throw invalid("Der Zahlungsmodus dieser Tour ist in der Niederlassung nicht mehr verfügbar."); + Integer cashStop=null;if(cash){var marked=db.queryForList("SELECT tr_sort FROM tour WHERE jb_id=? AND tr_remark LIKE ? ORDER BY tr_sort,tr_id",order,"%*** ZAHLT BAR ***%");if(marked.size()==1){var sorts=db.queryForList("SELECT tr_sort FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",order);for(int n=0;n();warnings.add("Die Kopie wird als neuer Sofortauftrag vorbereitet. Bitte Abholzeit und Angaben prüfen."); + 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(""); + 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.");} + var tracking=new CustomerEntryTracking(db);List emails=tracking.enabled(s.customer())?tracking.load(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",order).stream().map(t->id(t,"tr_id")).toList()):null; + return fields("acceptUntilTime",CustomerEntryAcceptance.load(j,s.acceptanceEnabled()),"trackingEmails",emails,"payment",cash?"CASH":"INVOICE","cashStop",cashStop,"costSplit",split,"sourceOrderId",order,"sourceRevision",source.revision(),"sourceName",text(j,"jb_tourname"),"payerCostCenterId",payer,"vehicleTypeId",type,"courierVehicleId",vehicle,"stops",source.stops(),"reference",reference,"notes",OrderDeliveryData.publicRemark(text(j,"jb_freetext_1")),"weight",s.dimensions()?decimal(j,"jb_weight"):BigDecimal.ZERO,"length",s.dimensions()?decimal(j,"jb_crvh_length"):BigDecimal.ZERO,"width",s.dimensions()?decimal(j,"jb_crvh_width"):BigDecimal.ZERO,"height",s.dimensions()?decimal(j,"jb_crvh_height"):BigDecimal.ZERO,"positions",s.dimensions()?decimal(j,"jb_crvh_position"):BigDecimal.ZERO,"filters",filters,"warnings",warnings); + });} + Pricing pricing(LegacyUser u,CustomerEntryAccess.State s,CustomerOrderEntry.Draft d){ + if(d.sourceOrderId()==null){if(d.sourceRevision()!=null&&!d.sourceRevision().isBlank())throw invalid("Der Quellauftrag fehlt.");return new Pricing(PriceMode.AUTO,null,null,null,null,null,null,false,4L);} + var source=source(u,s,d.sourceOrderId());if(!source.revision().equals(d.sourceRevision()))throw OrderEntryService.conflict("Der Quellauftrag wurde geändert. Bitte die Tour erneut laden."); + var j=source.job(); + boolean same=id(j,"jb_payment")== (d.payment()==Payment.CASH?2:d.costSplit()?1:0)&&payer(j)==d.payerCostCenterId()&&id(j,"vht_id")==d.vehicleTypeId()&&basis(source.stops()).equals(basis(d.stops())); + boolean fixed=same&&!source.zone()&&decimal(j,"jb_fixprice").signum()>0; + var taxes=db.queryForList("SELECT tx_id FROM tax WHERE tx_sign=?",text(j,"jb_sales_tax_rate_sign"));if(taxes.size()!=1)throw invalid("Der Steuerschlüssel der gespeicherten Tour ist nicht eindeutig."); + boolean discount=source.prices().stream().anyMatch(p->Set.of("Fixpreis","Servicepreis").contains(text(p,"trs_srv_name"))&&decimal(p,"trs_discount").signum()>0); + return new Pricing(fixed?PriceMode.FIXED:PriceMode.AUTO,fixed?decimal(j,"jb_fixprice"):null,decimal(j,"jb_serviceprice"),fixed?decimal(j,"jb_cr_subprice"):null,access.catalog.enabled(u.headquartersId(),"MASK_CR_PRICE_MODE")?source.courierService():null,null,null,discount,id(taxes.getFirst(),"tx_id")); + } + static long payer(Map j){if(id(j,"csc_id_payer_cash")>0)return id(j,"csc_id_payer_cash");if(id(j,"jb_payment")==2||id(j,"jb_payment")==1&&id(j,"csc_id_payer")<=0)return id(j,"csc_id");return id(j,"csc_id_payer");} + static List> basis(List stops){return stops.stream().map(s->List.of(Objects.toString(s.street(),""),Objects.toString(s.houseNumber(),""),Objects.toString(s.postalCode(),""),Objects.toString(s.city(),""),Objects.toString(s.country(),""))).toList();} + static String copyRemark(String note){return OrderDeliveryData.publicRemark(note).replace(CustomerPendingPrice.CASH,"").replace(CustomerEntryTracking.GPS_HINT,"").replace("*** ZAHLT BAR ***","").replaceAll("(?m)\\*\\*\\* Bitte im Auftrag STADTBOTE Bar [^\\r\\n]* kassieren und quittieren\\. \\*\\*\\*","").strip();} + static BigDecimal amount(String s){try{return s.isBlank()?BigDecimal.ZERO:new BigDecimal(s.replace(',','.'));}catch(NumberFormatException e){throw invalid("Ungültiger gespeicherter Servicepreis.");}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerEntryTracking.java b/backend/src/main/java/de/stadtbote/votian/CustomerEntryTracking.java new file mode 100644 index 0000000..c0ae137 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerEntryTracking.java @@ -0,0 +1,53 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** jobs2 stores the recipient before the private tracking token/status, separated by pipes. */ +final class CustomerEntryTracking { + static final String GPS_HINT="~~~\nBitte achten sie auf eine aktive GPS-Verbindung während dieses Auftrags, da der/die Empfänger über ihr voraussichtliches Eintreffen auf dem Laufenden gehalten wird.\n~~~\n"; + final DatabaseService db; + CustomerEntryTracking(DatabaseService db){this.db=db;} + boolean enabled(long customer){return id(one(db.queryForList("SELECT cs_tracking FROM customer WHERE cs_id=?",customer)),"cs_tracking")==1;} + static List validate(List emails,int stations,boolean enabled){ + if(emails==null)return null; // Older clients leave existing station metadata untouched. + if(!enabled)throw CustomerPortalScope.denied(); + if(emails.size()!=stations)throw bad("Bitte die Tracking-E-Mail jeder Station zuordnen."); + var result=new ArrayList(); + for(String raw:emails){ + if(raw==null||raw.length()>60||raw.contains("|")||raw.codePoints().anyMatch(Character::isISOControl))throw bad("Tracking-E-Mail: Bitte eine einzelne Adresse mit höchstens 60 Zeichen eingeben."); + String email=raw.strip();if(!email.isEmpty())email=AppointmentNotifications.address(email); + if(!email.isEmpty()&&!enabled)throw CustomerPortalScope.denied();result.add(email); + } + return List.copyOf(result); + } + List validate(CustomerEntryAccess.State state,CustomerOrderEntry.Draft draft){return draft.trackingEmails()==null?null:validate(draft.trackingEmails(),draft.stops().size(),enabled(state.customer()));} + List load(List stations){return stations.stream().map(id->recipient(raw(id))).toList();} + String raw(long station){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station);if(rows.size()>1)throw bad("Die Tracking-E-Mail einer Station ist mehrfach gespeichert. Bitte den Bestand klären.");return rows.isEmpty()?"":text(rows.getFirst(),"gdc_content");} + static String recipient(String raw){return raw.split("\\|",-1)[0];} + void save(long station,String email){ + String old=raw(station);if(recipient(old).equals(email))return; + if(email.isEmpty()){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station);return;} + int separator=old.indexOf('|');String value=email+(separator<0?"":old.substring(separator)); + int changed=db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",value,station); + if(changed==0)db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",station,"gdc_gen_fieldname","tr_tracking","gdc_content",value,"gdc_context","")); + } + void created(long job,List emails){ + if(emails==null)return;var stops=db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id",job); + if(stops.size()!=emails.size())throw bad("Die Tracking-E-Mails konnten nicht eindeutig den gespeicherten Stationen zugeordnet werden."); + for(int n=0;n!recipient(raw(id(t,"tr_id"))).isBlank()); + for(int n=0;n10000)throw bad("Die Stationsbemerkung ist mit dem Tracking-Hinweis zu lang."); + if(!old.equals(value))db.update("UPDATE tour SET tr_remark=? WHERE tr_id=?",value,id(stop,"tr_id")); + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportData.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportData.java new file mode 100644 index 0000000..c78ce92 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportData.java @@ -0,0 +1,90 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Typed customer export projections; database-defined SQL is never executed. */ +final class CustomerExportData { + record Scope(long customer,long base,long mandator,Set ownCenters,Set childCenters) { + Set allCenters(){var ids=new TreeSet<>(ownCenters);ids.addAll(childCenters);return ids;} + } + record Query(LocalDate from,LocalDate to,int category) {} + static final Set CATEGORIES=Set.of(110,115,120); + static final Set SERVICE=Set.of("jb_id","tr_sort","srv_name","srvt_name","jbc_amount","jbc_price","jbc_totalprice"); + static final Set NUMBERS=Set.of("jb_id","tr_sort","price","gross_price","tax_rate","jbc_amount","jbc_price","jbc_totalprice"); + static final Set DATES=Set.of("jb_ordertime","jb_warningtime","jb_finishtime","tr_finishtime"); + final DatabaseService db; + CustomerExportData(DatabaseService db){this.db=db;} + Scope scope(LegacyUser u){ + var account=CustomerPortalScope.verify(db,u);if(!u.customerRight(7)||!EmployeeAdministration.bit(text(account,"emp_rights"),7))throw CustomerPortalScope.denied(); + long customer=id(account,"cs_id"),base=id(account,"csc_id"),root=id(account,"customer_root"),md=mandator(db,u); + var own=centers(customer,root,base);if(!own.contains(base))throw CustomerPortalScope.denied();var child=new TreeSet(); + // The PHP meta-customer export spans direct child customers. A sub-costcenter identity stays in its subtree. + var children=base==root?db.queryForList("SELECT cs_id,csc_id FROM customer WHERE cs_id_parent=? AND cs_id<>? AND hq_id IN ("+CourierBourse.HQ_SQL+") ORDER BY cs_id LIMIT 502",customer,customer,md):List.>of(); + if(children.size()>501)throw bad("Der Kundenverbund ist zu groß für den Export."); + for(var row:children){ + child.addAll(centers(id(row,"cs_id"),id(row,"csc_id"),id(row,"csc_id")));if(child.size()>10000)throw bad("Zu viele Kostenstellen für den Export."); + } + return new Scope(customer,base,md,Set.copyOf(own),Set.copyOf(child)); + } + Set centers(long customer,long root,long base){var rows=db.queryForList("SELECT csc_id AS id,csc_pre_id AS parent FROM costcenter WHERE cs_id=? LIMIT 10001",customer);if(rows.size()>10000)throw bad("Zu viele Kostenstellen für den Export.");var valid=new HashSet<>(PortalEmployees.validCenters(rows,root));valid.retainAll(PortalEmployees.validCenters(rows,base));return valid;} + static Set keys(int category){ + if(category==115)return SERVICE; + if(category==120)return Set.of("jb_id","jb_ordertime","jb_warningtime","jb_finishtime","cr_sid","cs_eid","cmp_comp","cmp_comp2","cmp_comp3","cmp_comp4","tr_sort","tr_comp","tr_person","tr_commission_no","tr_finishtime","ad_street","tr_hsno","ad_zipcode","ad_city","srv_name","srvt_name","jbc_amount","jbc_price","jbc_totalprice"); + if(category!=110)return Set.of(); + var keys=new HashSet<>(List.of("jb_id","cr_sid","price","gross_price","orderdate","ordertime","finishdate","finishtime","bookdate","booktime","jb_invtext","tax_rate","tax_rate_sign","cs_eid","csc_name","inv_no","invdate","invtime","usr_firstname","usr_name","target_address","streethsno","ad_country","vehicletype")); + for(int n=1;n<=20;n++){String suffix=n==1?"":Integer.toString(n);for(String f:List.of("comp","comp2","hsno","person","commission_no","cs_freetext","signname"))keys.add("tr"+suffix+"_"+f);for(String f:List.of("street","zipcode","city"))keys.add("ad"+suffix+"_"+f);if(n>2)keys.add("target_address_"+n);}return keys; + } + List> rows(LegacyUser u,Scope s,Query q){ + if(q==null||!CATEGORIES.contains(q.category())||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte eine Exportkategorie und einen Zeitraum von höchstens einem Jahr wählen."); + var all=s.allCenters();String marks=CourierPortal.placeholders(all.size());var args=new ArrayList(all);String condition="jb.csc_id_payer IN ("+marks+")"; + if(q.category()==110&&!s.childCenters().isEmpty()){condition="("+condition+" OR EXISTS (SELECT 1 FROM tourservice st WHERE st.jb_id=jb.jb_id AND st.csc_id IN ("+CourierPortal.placeholders(s.childCenters().size())+")))";args.addAll(new TreeSet<>(s.childCenters()));} + args.add(s.mandator());args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay()); + var jobs=db.queryForList("SELECT jb.jb_id,jb.hq_id,jb.csc_id_payer,jb.jb_payment,jb.emp_id,jb.cr_sid,jb.vht_id,jb.jb_totalprice,jb.jb_sales_tax_rate,jb.jb_sales_tax_rate_sign,jb.jb_ordertime,jb.jb_warningtime,jb.jb_finishtime,jb.jb_booktime,jb.jb_invtext FROM job jb WHERE "+condition+" AND jb.hq_id IN ("+CourierBourse.HQ_SQL+") AND jb.jb_status=2 AND COALESCE(jb.jb_storno,0) IN (0,1,3) AND jb.jb_finishtime>=? AND jb.jb_finishtime2000)throw bad("Mehr als 2.000 Exportaufträge. Bitte den Zeitraum eingrenzen.");if(jobs.isEmpty())return List.of(); + var ids=jobs.stream().map(j->id(j,"jb_id")).toList();String jobMarks=CourierPortal.placeholders(ids.size()); + var centers=new HashMap>();for(var c:db.queryForList("SELECT cc.csc_id,cc.csc_name,cs.cs_id,cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2,cmp.cmp_comp3,cmp.cmp_comp4 FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id IN ("+marks+")",all.toArray()))centers.put(id(c,"csc_id"),c); + var tours=group(bounded("SELECT t.tr_id,t.jb_id,t.csc_id,t.tr_sort,t.tr_comp,t.tr_comp2,t.tr_hsno,t.tr_person,t.tr_commission_no,t.tr_cs_freetext,t.tr_signname,t.tr_finishtime,a.ad_id AS valid_address,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id IN ("+jobMarks+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",ids),"jb_id"); + var prices=q.category()==110?group(bounded("SELECT jb_id,csc_id,trs_price,trs_discount FROM tourservice WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id",ids),"jb_id"):Map.>>of(); + var calc=q.category()!=110?group(bounded("SELECT jb_id,tr_sort,srv_name,srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,tr_sort,srv_id,srvt_id,tr_id",ids),"jb_id"):Map.>>of(); + var invoices=q.category()==110?group(bounded("SELECT jb_id,inv_no,inv_datetime FROM invoice WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,inv_datetime,inv_no",ids),"jb_id"):Map.>>of(); + var vehicleNames=new HashMap();for(var v:db.queryForList("SELECT mt_sort,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort"))vehicleNames.put(id(v,"mt_sort"),text(v,"label")); + var creators=new HashMap>();if(q.category()==110){var users=jobs.stream().map(j->id(j,"emp_id")).filter(i->i>0).distinct().toList();if(!users.isEmpty())for(var row:db.queryForList("SELECT u.usr_id,u.usr_name,u.usr_firstname,e.csc_id FROM user u JOIN employee e ON e.usr_id=u.usr_id WHERE u.usr_type=2 AND u.usr_id IN ("+CourierPortal.placeholders(users.size())+")",users.toArray()))if(all.contains(id(row,"csc_id")))creators.put(id(row,"usr_id"),row);} + int hops=2;try{hops=Math.max(2,Math.min(20,Integer.parseInt(new OrderEntryCatalog(db).parameter(0,"JOBLIST_NUM_OF_HOPS"))));}catch(NumberFormatException ignored){} + var out=new ArrayList>(); + for(var job:jobs){long jobId=id(job,"jb_id"),payer=id(job,"csc_id_payer");var stops=tours.getOrDefault(jobId,List.of());var items=calc.getOrDefault(jobId,List.of());boolean split=payer==0||id(job,"jb_payment")==1; + if(q.category()==110){ + if(stops.stream().noneMatch(t->id(t,"tr_sort")==1&&id(t,"valid_address")>0)||!vehicleNames.containsKey(id(job,"vht_id")))continue; + var groups=new TreeMap>>();for(var p:prices.getOrDefault(jobId,List.of())){long center=id(p,"csc_id");if(all.contains(center)&&(center==payer||s.childCenters().contains(center)))groups.computeIfAbsent(center,i->new ArrayList<>()).add(p);} + for(var group:groups.entrySet()){var center=centers.get(group.getKey());if(center==null)continue;var row=base(job,center);var sum=BigDecimal.ZERO;for(var p:group.getValue())sum=sum.add(BillingService.nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(BillingService.nz(decimal(p,"trs_discount")))).movePointLeft(2)); + row.put("price",BillingService.money(sum));row.put("gross_price",BillingService.money((split?sum:BillingService.nz(decimal(job,"jb_totalprice"))).multiply(BigDecimal.ONE.add(BillingService.nz(decimal(job,"jb_sales_tax_rate")).movePointLeft(2)))));row.put("tax_rate",job.get("jb_sales_tax_rate"));row.put("tax_rate_sign",text(job,"jb_sales_tax_rate_sign"));row.put("jb_invtext",split?"":text(job,"jb_invtext"));row.put("vehicletype",vehicleNames.get(id(job,"vht_id"))); + row.put("orderdate",date(job,"jb_ordertime","yyyyMMdd"));row.put("ordertime",date(job,"jb_ordertime","HH:mm"));row.put("finishdate",date(job,"jb_finishtime","ddMMyy"));row.put("finishtime",date(job,"jb_finishtime","HH:mm"));row.put("bookdate",date(job,"jb_booktime","yyyyMMdd"));row.put("booktime",date(job,"jb_booktime","HH:mm")); + var creator=creators.get(id(job,"emp_id"));row.put("usr_name",creator==null?"":text(creator,"usr_name"));row.put("usr_firstname",creator==null?"":text(creator,"usr_firstname")); + var invoice=invoices.getOrDefault(jobId,List.of());if(invoice.size()>1&&invoice.stream().map(i->text(i,"inv_no")).distinct().count()>1)throw bad("Auftrag #"+jobId+" hat mehrere Rechnungsnummern. Bitte die Zuordnung prüfen.");if(!invoice.isEmpty()){var inv=invoice.getFirst();row.put("inv_no",text(inv,"inv_no"));row.put("invdate",date(inv,"inv_datetime","ddMMyy"));row.put("invtime",date(inv,"inv_datetime","HH:mm"));} + for(int n=1;n<=hops;n++){int sort=n;var station=stops.stream().filter(t->id(t,"tr_sort")==sort&&(!split||id(t,"csc_id")==group.getKey())).toList();if(station.size()>1)throw bad("Auftrag #"+jobId+" hat eine mehrdeutige Stationsposition "+n+".");if(!station.isEmpty())station(row,station.getFirst(),n);} + out.add(row); + } + }else{ + if(!all.contains(payer))continue;var center=centers.get(payer);if(center==null)continue; + if(split)throw bad("Auftrag #"+jobId+": Kalkulationspositionen eines Kostensplits sind nicht eindeutig dem Zahler zugeordnet. Bitte den Auftragsdatenexport verwenden."); + if(q.category()==115){for(var item:items){var row=new LinkedHashMap();SERVICE.forEach(k->row.put(k,item.get(k)));out.add(row);}} + else if(items.isEmpty()){var row=base(job,center);row.putAll(fields("tr_sort",1,"jbc_amount",1,"jbc_price",job.get("jb_totalprice"),"jbc_totalprice",job.get("jb_totalprice")));out.add(row);} + else for(var item:items){long sort=id(item,"tr_sort");var row=base(job,center);for(String k:SERVICE)row.put(k,item.get(k));if(sort==0)row.put("tr_sort",1);else{var matches=stops.stream().filter(t->id(t,"tr_sort")==sort).toList();if(matches.isEmpty())continue;if(matches.size()>1)throw bad("Auftrag #"+jobId+" hat eine mehrdeutige Stationsposition "+sort+".");var t=matches.getFirst();for(String k:List.of("tr_comp","tr_person","tr_commission_no","tr_finishtime","ad_street","tr_hsno","ad_zipcode","ad_city"))row.put(k,t.get(k));}out.add(row);} + } + if(out.size()>10000)throw bad("Mehr als 10.000 Exportzeilen. Bitte den Zeitraum eingrenzen."); + } + if(q.category()!=110)out.sort(Comparator.comparingLong((Map r)->id(r,"jb_id")).thenComparingLong(r->id(r,"tr_sort"))); + return out; + } + List> bounded(String sql,List ids){var rows=db.queryForList(sql+" LIMIT 50001",ids.toArray());if(rows.size()>50000)throw bad("Zu viele Exportpositionen. Bitte den Zeitraum eingrenzen.");return rows;} + static Map>> group(List> rows,String key){var grouped=new HashMap>>();for(var row:rows)grouped.computeIfAbsent(id(row,key),i->new ArrayList<>()).add(row);return grouped;} + static Map base(Map job,Map center){var row=new LinkedHashMap();for(String k:List.of("jb_id","jb_ordertime","jb_warningtime","jb_finishtime","cr_sid"))row.put(k,job.get(k));for(String k:List.of("cs_eid","csc_name","cmp_comp","cmp_comp2","cmp_comp3","cmp_comp4"))row.put(k,center.get(k));return row;} + static String date(Map row,String key,String pattern){var d=dateTime(row,key);return d==null?"":d.format(DateTimeFormatter.ofPattern(pattern));} + static void station(Map row,Map t,int n){String suffix=n==1?"":Integer.toString(n);for(String k:List.of("comp","comp2","hsno","person","commission_no","cs_freetext","signname"))row.put("tr"+suffix+"_"+k,text(t,"tr_"+k));for(String k:List.of("street","zipcode","city"))row.put("ad"+suffix+"_"+k,text(t,"ad_"+k));if(n==1){row.put("ad_country",text(t,"ad_country"));row.put("streethsno",(text(t,"ad_street")+" "+text(t,"tr_hsno")).strip());}else row.put(n==2?"target_address":"target_address_"+n,(text(t,"ad_street")+" "+text(t,"tr_hsno")+", "+text(t,"ad_zipcode")).strip());} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java new file mode 100644 index 0000000..9575183 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportFiles.java @@ -0,0 +1,58 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Stored PHP exports are customer-wide: historical files have no costcenter access manifest. */ +@RestController +@RequestMapping("/api/customer-portal/export-files") +class CustomerExportFiles { + static final int MAX_BYTES=64_000_000; + record Access(long customer,boolean root) {} + final DatabaseService db; + final DocumentStore store; + CustomerExportFiles(DatabaseService db,ExportFileStore store){this.db=db;this.store=store.store;} + @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")); + } + 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. + long root=id(a,"customer_root"),base=id(a,"csc_id");var centers=new CustomerExportData(db).centers(id(a,"cs_id"),root,base);if(!centers.contains(base))throw CustomerPortalScope.denied(); + String path=new OrderEntryCatalog(db).parameter(0,"EXPORT_PATH");if(!path.isBlank()&&!path.replaceAll("/+$","").equals("../export/download"))throw bad("Die konfigurierte PHP-Exportablage muss vor dem Dateizugriff abgeglichen werden."); + return new Access(id(a,"cs_id"),base==root); + } + static String physical(String value){if(value==null||!value.matches("[A-Za-z0-9][A-Za-z0-9_.-]{0,99}")||value.contains(".."))throw bad("Der gespeicherte Exportpfad ist ungültig.");return value;} + Map list(LegacyUser u,int page){return db.readTransaction(()->{ + if(page<0||page>10000)throw bad("Ungültige Dateiseite.");var a=access(u); + if(!a.root())return fields("files",List.of(),"page",0,"hasNext",false,"available",false,"message","Gespeicherte Exportdateien können Daten aller Kostenstellen enthalten. Die Ablage ist deshalb nur vom Kundenhauptzugang erreichbar."); + var files=db.queryForList("SELECT expf_id,expf_name,expf_cryptname,expf_storetime,expf_ftpupload FROM exportfiles WHERE hq_id=? AND cs_id=? ORDER BY expf_storetime DESC,expf_id DESC LIMIT 51 OFFSET ?",u.headquartersId(),a.customer(),page*50); + boolean available=store.configured();Map entries=new HashMap<>();if(available)try(var fs=store.open()){for(var e:fs.list(""))if(!e.directory())entries.put(e.name(),e);} + var result=new ArrayList>();for(var row:files.stream().limit(50).toList()){ + String name=text(row,"expf_name"),crypt=text(row,"expf_cryptname");var file=entries.get(crypt);boolean valid=true;try{physical(crypt);}catch(RuntimeException e){valid=false;} + boolean shared=!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=? AND (cs_id<>? OR hq_id<>?) LIMIT 1",crypt,a.customer(),u.headquartersId()).isEmpty(); + boolean otherReference=!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=? AND expf_id<>? LIMIT 1",crypt,id(row,"expf_id")).isEmpty(); + result.add(fields("uploaded",id(row,"expf_ftpupload")==1,"canRemove",available&&valid&&!otherReference&&(file==null||file.size()<=MAX_BYTES),"id",id(row,"expf_id"),"name",name,"stored",row.get("expf_storetime"),"size",file==null?0:file.size(),"canDownload",available&&valid&&!shared&&file!=null&&file.size()<=MAX_BYTES,"status",!valid?"Ungültiger Ablagepfad":shared?"Mehrdeutige Dateizuordnung":!available?"Ablage nicht eingerichtet":file==null?"Datei fehlt in der Ablage":file.size()>MAX_BYTES?"Datei größer als 64 MB":"Verfügbar")); + } + return fields("files",result,"page",page,"hasNext",files.size()>50,"available",available,"message",available?"":"Die gemeinsame Exportablage ist noch nicht eingerichtet."); + });} + record FileRef(Access access,String name,String physical) {} + FileRef file(LegacyUser u,long id){ + var a=access(u);if(!a.root())throw CustomerPortalScope.denied();var row=one(db.queryForList("SELECT expf_id,expf_name,expf_cryptname FROM exportfiles WHERE expf_id=? AND cs_id=? AND hq_id=?",id,a.customer(),u.headquartersId()));String crypt=physical(text(row,"expf_cryptname")); + if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=? AND (cs_id<>? OR hq_id<>?) LIMIT 1",crypt,a.customer(),u.headquartersId()).isEmpty())throw bad("Die physische Datei ist mehreren Kunden oder Niederlassungen zugeordnet."); + String name=text(row,"expf_name");DocumentStore.component(name);return new FileRef(a,name,crypt); + } + Map download(LegacyUser u,long id){ + var before=db.readTransaction(()->file(u,id));byte[] bytes;try(var fs=store.open()){bytes=fs.read(before.physical(),MAX_BYTES);} + // A new transaction after I/O observes revoked access or a changed file association. + if(!db.readTransaction(()->file(u,id)).equals(before))throw CustomerPortalScope.denied();return fields("name",before.name(),"data",bytes); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java new file mode 100644 index 0000000..476e3ab --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportFormat.java @@ -0,0 +1,98 @@ +package de.stadtbote.votian; + +import java.math.*; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.regex.*; +import static de.stadtbote.votian.AdminSupport.bad; + +/** Bounded interpreter for PHP export profiles. No SQL, PHP, JavaScript or expression evaluation. */ +final class CustomerExportFormat { + record Field(long id,String key,String label,String type) {} + record Rule(long field,Integer length,String direction,int sort,String extra,boolean noDelimiter, + String fill,Integer format,String condition,Integer from,Integer to,boolean leftRight) {} + record Settings(List rules,String lineStart,String lineEnd,String fileStart,String fileEnd,String headline) {} + static final int MAX_OUTPUT=10_000_000; + static final Set MONEY=Set.of("price","gross_price","tax_rate","jbc_amount","jbc_price","jbc_totalprice"); + static String type(String key){return CustomerExportData.DATES.contains(key)?"datetime":MONEY.contains(key)?"float":CustomerExportData.NUMBERS.contains(key)?"number":"text";} + static String blank(String s){return s==null||s.strip().equals("%")?"":s;} + static Integer integer(String s){s=blank(s).strip();if(s.isEmpty())return null;try{return Integer.valueOf(s);}catch(NumberFormatException e){throw bad("Ungültige Zahl im Exportprofil.");}} + static List decode(String value){ + if(value==null||value.isBlank())return List.of();if(value.length()>300000)throw bad("Das Exportprofil ist zu groß.");var rules=new ArrayList(); + for(String item:value.split("-;-",-1)){var parts=item.split("-,-",-1);if(parts.length<4||parts.length>12)throw bad("Das alte Exportprofil enthält eine ungültige Felddefinition.");String[] p=new String[12];Arrays.fill(p,"");System.arraycopy(parts,0,p,0,parts.length);var id=integer(p[0]);var sort=integer(p[3]);if(id==null)throw bad("Exportfeld fehlt.");rules.add(new Rule(id,integer(p[1]),blank(p[2]),sort==null?0:sort,blank(p[4]),p[5].equals("1"),p[6].isEmpty()?" ":p[6],integer(p[7]),blank(p[8]),integer(p[9]),integer(p[10]),p[11].equals("1")));} + return rules; + } + static String encode(List rules){return rules.stream().map(r->String.join("-,-",Long.toString(r.field()),Objects.toString(r.length(),""),r.direction(),Integer.toString(r.sort()),r.extra(),r.noDelimiter()?"1":"0",r.fill(),Objects.toString(r.format(),""),r.condition(),Objects.toString(r.from(),""),Objects.toString(r.to(),""),r.leftRight()?"1":"0")).collect(java.util.stream.Collectors.joining("-;-"));} + static void validate(Settings s,List fields){ + if(s==null||s.rules()==null||s.rules().isEmpty()||s.rules().size()>300)throw bad("Bitte mindestens ein Exportfeld auswählen."); + var ids=new HashSet();var positions=new HashSet();var known=new HashMap();fields.forEach(f->known.put(f.id(),f)); + for(var r:s.rules()){ + if(r!=null&&!known.containsKey(r.field()))throw bad("Das Altprofil verweist auf nicht mehr definierte Exportfelder. Bitte die unbekannten Felder entfernen und die benötigten Felder neu auswählen."); + if(r==null||!ids.add(r.field())||r.sort()<0||r.sort()>=fields.size()||!positions.add(r.sort()))throw bad("Exportfelder und Sortierpositionen müssen eindeutig sein."); + if(r.length()!=null&&(r.length()<0||r.length()>10000)||r.direction()==null||!Set.of("","l","r").contains(r.direction())||r.fill()==null||r.fill().codePointCount(0,r.fill().length())!=1||r.extra()==null||r.extra().length()>4000||r.condition()==null||r.condition().length()>1000||r.from()!=null&&(r.from()<0||r.from()>10000)||r.to()!=null&&(r.to()<0||r.to()>10000)||!r.leftRight()&&r.from()!=null&&r.to()!=null&&r.from()>r.to())throw bad("Ungültige Länge, Ausrichtung oder Teilwortregel im Exportprofil."); + for(String v:List.of(r.direction(),r.extra(),r.fill(),r.condition()))if(v.contains("-,-")||v.contains("-;-")||v.indexOf('\0')>=0)throw bad("Ein Exportfeld enthält reservierte Steuerzeichen."); + var f=known.get(r.field());if(r.format()!=null&&!(f.type().equals("float")&&r.format()>=1&&r.format()<=3||f.type().equals("datetime")&&r.format()>=2&&r.format()<=14))throw bad("Dieses Format passt nicht zum Exportfeld "+f.label()+"."); + condition(r.condition(),Map.of(),fields);tags(r.extra(),Map.of(),fields,0); + } + for(String value:Arrays.asList(s.lineStart(),s.lineEnd(),s.fileStart(),s.fileEnd(),s.headline()))if(value==null||value.length()>10000||value.indexOf('\0')>=0)throw bad("Ungültiger Zusatztext im Exportprofil."); + if(s.lineStart().length()>150||s.lineEnd().length()>150||s.fileEnd().length()>150)throw bad("Zeilenanfang/-ende und Dateiende dürfen höchstens 150 Zeichen enthalten."); + tags(s.lineStart(),Map.of(),fields,0);tags(s.lineEnd(),Map.of(),fields,0); + } + 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(); + 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())); + if(condition(r.condition(),row,fields)){v=format(v,f.type(),r.format());if(r.from()!=null||r.to()!=null){if(r.leftRight()){int left=Objects.requireNonNullElse(r.from(),0),right=Objects.requireNonNullElse(r.to(),0);v=substring(v,0,left).strip()+substring(v,Math.max(0,v.length()-right),v.length()).strip();}else v=substring(v,Objects.requireNonNullElse(r.from(),0),r.to()==null?v.length():r.to()+1).strip();}v=width(v,r.length(),r.fill(),r.direction().equals("l"));} + else v=" ".repeat(Objects.requireNonNullElse(r.length(),0));values.add(v); + } + var line=new StringBuilder(tags(s.lineStart(),row,fields,rowIndex)); + for(int n=0;nMAX_OUTPUT)throw bad("Die Exportdatei überschreitet 10 MB. Bitte den Zeitraum eingrenzen."); + } + if(!s.fileEnd().isEmpty())out.append(s.fileEnd()).append('\n');if(out.length()>MAX_OUTPUT)throw bad("Die Exportdatei ist zu groß.");if(out.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length>MAX_OUTPUT)throw bad("Die Exportdatei überschreitet 10 MB. Bitte den Zeitraum eingrenzen.");return out.toString(); + } + static String wipe(Object value){return Objects.toString(value,"").replace("\r","").replace("\n","");} + static String substring(String s,int from,int end){return s.substring(Math.min(from,s.length()),Math.max(Math.min(from,s.length()),Math.min(end,s.length())));} + static String width(String s,Integer length,String fill,boolean left){if(length==null)return s;if(s.length()>=length)return s.substring(0,length);String pad=fill.repeat(length-s.length());return left?s+pad:pad+s;} + static String format(String value,String type,Integer format){ + if(format==null||value.isBlank())return value; + if(type.equals("float"))try{String n=new BigDecimal(value.replace(',','.')).setScale(2,RoundingMode.HALF_UP).toPlainString();return format==2?n.replace('.',','):format==3?n.replace(".",""):n;}catch(NumberFormatException e){throw bad("Ein Zahlenfeld enthält keinen gültigen Zahlenwert.");} + if(type.equals("datetime")){LocalDateTime date;try{String input=value.strip().replace(' ','T');date=input.length()==10?LocalDate.parse(input).atStartOfDay():LocalDateTime.parse(input);}catch(RuntimeException e){throw bad("Ein Datumsfeld enthält keinen gültigen Zeitwert.");}String p=switch(format){case 2->"HH.mm.ss";case 3->"dd.MM.yy' : 'HH.mm.ss";case 4->"dd.MM.yyyy";case 5->"dd.MM.yy";case 6->"yy.MM.dd";case 7->"yyMMdd";case 8->"yyyy.MM.dd";case 9->"yyyyMMdd";case 10->"HH:mm";case 11->"MM/yy";case 12->"yyyyMMddHHmmss";case 13->date.getYear()==LocalDate.now().getYear()?"HH:mm '('dd.MM.')'":"HH:mm '('dd.MM.yy.')'";case 14->"HH:mm '('dd.MM.yyyy')'";default->throw bad("Unbekanntes Datumsformat.");};return date.format(DateTimeFormatter.ofPattern(p));}return value; + } + static Field indexed(String index,List fields){Integer n=integer(index);if(n==null||n<0||n>=fields.size())throw bad("Ein Exportverweis zeigt auf ein unbekanntes Feld.");return fields.get(n);} + static boolean condition(String condition,Map row,List fields){if(condition.isBlank())return true;boolean active=true;for(String part:condition.split("/",-1)){int eq=part.indexOf('=');if(eq<1)throw bad("Bedingungen benötigen Feldnummer=Wert, getrennt durch /.");var field=indexed(part.substring(0,eq),fields);active&=wipe(row.get(field.key())).equals(part.substring(eq+1).strip());}return active;} + static String tags(String template,Map row,List fields,int rowIndex){return tags(template,row,fields,rowIndex,0);} + // Parse the template before inserting values; data containing tag-looking text stays literal. + static String tags(String template,Map row,List fields,int rowIndex,int depth){ + if(depth>12)throw bad("Exportplatzhalter sind zu tief verschachtelt."); + var open=Pattern.compile("<(dbfield|dbfieldsub|date,[0-9]+|iter,[0-9]+|lfd,[0-9]+)>").matcher(template); + var out=new StringBuilder();int at=0,count=0; + while(open.find(at)){ + if(++count>300)throw bad("Zu viele Exportplatzhalter.");String before=template.substring(at,open.start());invalidTags(before);out.append(before); + String tag=open.group(1),kind=tag.split(",")[0],close="";int end=template.indexOf(close,open.end());if(end<0)throw bad("Ein Exportplatzhalter ist unvollständig."); + String inner=template.substring(open.end(),end),value; + if(kind.equals("dbfield"))value=wipe(row.get(indexed(inner,fields).key())); + else if(kind.equals("dbfieldsub")){var p=inner.split(",",-1);if(p.length<3||p.length>4)throw bad("Ungültiger Teilwortverweis.");int from=bounded(p[1]),to=bounded(p[2]);if(to14)throw bad("Unbekanntes Export-Datumsformat.");value=format(content,"datetime",n);} + else if(kind.equals("iter")){if((long)n*content.length()>10000)throw bad("Die Exportwiederholung ist zu groß.");value=content.repeat(n);} + else value=content+width(Integer.toString(rowIndex),n,"0",false); + } + out.append(value);if(out.length()>20000)throw bad("Ein Exportzusatz ist zu groß.");at=end+close.length(); + } + String tail=template.substring(at);invalidTags(tail);out.append(tail);if(out.length()>20000)throw bad("Ein Exportzusatz ist zu groß.");return out.toString(); + } + static void invalidTags(String value){if(Pattern.compile("]|iter[,>]|lfd[,>])").matcher(value).find())throw bad("Ein Exportplatzhalter ist unvollständig oder ungültig.");} + static int bounded(String value){Integer n=integer(value);if(n==null||n<0||n>10000)throw bad("Ungültige Exportplatzhalterlänge.");return n;} + private CustomerExportFormat(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportRemoval.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportRemoval.java new file mode 100644 index 0000000..dfe7197 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportRemoval.java @@ -0,0 +1,68 @@ +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.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Recoverable removal of PHP export files with a durable SQL/SFTP handover. */ +@RestController +@RequestMapping("/api/customer-portal/export-removal") +class CustomerExportRemoval { + record Command(String requestId,long file,String revision) {} + record Snapshot(long file,long headquarters,long customer,String name,String physical,LocalDateTime stored,int uploaded) { + Map values(){return fields("expf_id",file,"hq_id",headquarters,"cs_id",customer,"expf_name",name,"expf_cryptname",physical,"expf_storetime",stored,"expf_ftpupload",uploaded);} + } + record Operation(String requestId,long actor,String intent,Snapshot snapshot,String digest,boolean missing,String status) { + Operation status(String next){return new Operation(requestId,actor,intent,snapshot,digest,missing,next);} + } + final DatabaseService db;final BusinessSupport support;final CustomerExportFiles files;final DocumentStore store; + CustomerExportRemoval(DatabaseService db,BusinessSupport support,CustomerExportFiles files,ExportFileStore store){this.db=db;this.support=support;this.files=files;this.store=store.store;} + @GetMapping Object list(Authentication a){return list(ApiController.user(a));} + @GetMapping("/{file}") Object preview(Authentication a,@PathVariable long file){return preview(ApiController.user(a),file);} + @PostMapping Object remove(Authentication a,@RequestBody Command c){return remove(ApiController.user(a),c);} + @PostMapping("/{requestId}/resume") Object resume(Authentication a,@PathVariable String requestId){return resume(ApiController.user(a),requestId);} + @PostMapping("/{requestId}/restore") Object restore(Authentication a,@PathVariable String requestId){return restore(ApiController.user(a),requestId);} + long customer(LegacyUser u){var a=files.access(u);if(!a.root())throw CustomerPortalScope.denied();return a.customer();} + Snapshot snapshot(LegacyUser u,long file){var a=files.file(u,file);var r=one(db.queryForList("SELECT * FROM exportfiles WHERE expf_id=?",file));return new Snapshot(file,u.headquartersId(),a.access().customer(),a.name(),a.physical(),dateTime(r,"expf_storetime"),(int)id(r,"expf_ftpupload"));} + void exclusive(Snapshot s){if(db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=? AND expf_id<>?",s.physical(),s.file()).size()>0)throw bad("Die physische Datei hat weitere Verweise und kann nicht entfernt werden.");} + Operation decode(Map row){try{var op=support.json.readValue(text(row,"gdc_content"),Operation.class);token(op.requestId());CustomerExportFiles.physical(op.snapshot().physical());DocumentStore.component(op.snapshot().name());if(!Set.of("PENDING","REMOVED","RESTORING","RESTORED").contains(op.status())||(!op.missing()&&!op.digest().matches("[0-9a-f]{64}")))throw new IllegalArgumentException();return op;}catch(Exception e){throw bad("Der gespeicherte Dateivorgang ist unvollständig.");}} + Operation find(long customer,String request){var r=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexdel' AND gdc_obj_id=? AND gdc_gen_fieldname=?",customer,token(request));return r.isEmpty()?null:decode(one(r));} + void actor(LegacyUser u,long customer,Operation op){if(op.actor()!=u.userId()||op.snapshot().headquarters()!=u.headquartersId()||op.snapshot().customer()!=customer)throw CustomerPortalScope.denied();} + Map list(LegacyUser u){return db.readTransaction(()->{var a=files.access(u);if(!a.root())return fields("operations",List.of());var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexdel' AND gdc_obj_id=? AND gdc_context<>'RESTORED' ORDER BY gdc_id DESC LIMIT 501",a.customer());if(rows.size()>500)throw bad("Mehr als 500 entfernte Exportdateien. Bitte die Ablage prüfen.");return fields("operations",rows.stream().map(this::decode).filter(op->op.actor()==u.userId()&&op.snapshot().headquarters()==u.headquartersId()).map(op->fields("requestId",op.requestId(),"name",op.snapshot().name(),"status",op.status(),"missing",op.missing())).toList());});} + Map preview(LegacyUser u,long file){return db.readTransaction(()->{var s=snapshot(u,file);exclusive(s);boolean missing;String digest;try(var fs=store.open()){missing=!fs.exists(s.physical());digest=missing?"":DocumentStore.digest(fs.read(s.physical(),CustomerExportFiles.MAX_BYTES));}return fields("file",file,"name",s.name(),"missing",missing,"revision",revision(List.of(s,digest,missing)));});} + Map remove(LegacyUser u,Command c){ + if(c==null||c.file()<=0)throw bad("Exportdatei fehlt.");token(c.requestId());String intent=revision(List.of("CUSTOMER_EXPORT_REMOVE",c));support.transactional("exportfiles","genericdatacontainer"); + db.customerExportFileWriteTransaction(()->{long customer=customer(u);var old=find(customer,c.requestId());if(old!=null){actor(u,customer,old);if(!old.intent().equals(intent))throw bad("Diese Vorgangskennung wurde bereits anders verwendet.");return null;} + var s=snapshot(u,c.file());exclusive(s);boolean missing;String digest;try(var fs=store.open()){missing=!fs.exists(s.physical());digest=missing?"":DocumentStore.digest(fs.read(s.physical(),CustomerExportFiles.MAX_BYTES));}unchanged(List.of(s,digest,missing),c.revision()); + var others=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexdel' AND gdc_obj_id=? AND gdc_context IN ('PENDING','RESTORING') LIMIT 501",customer);if(others.size()>500)throw bad("Zu viele offene Dateivorgänge.");for(var r:others)if(decode(r).snapshot().file()==s.file())throw bad("Für diese Datei gibt es bereits einen offenen Vorgang."); + var op=new Operation(c.requestId(),u.userId(),intent,s,digest,missing,"PENDING");db.insert("genericdatacontainer",fields("gdc_obj_type","ngexdel","gdc_obj_id",customer,"gdc_gen_fieldname",c.requestId(),"gdc_content",support.encode(op),"gdc_context",op.status()));return null; + });return resume(u,c.requestId()); + } + void save(Operation op){db.update("UPDATE genericdatacontainer SET gdc_context=?,gdc_content=? WHERE gdc_obj_type='ngexdel' AND gdc_obj_id=? AND gdc_gen_fieldname=?",op.status(),support.encode(op),op.snapshot().customer(),op.requestId());} + void matching(DocumentStore.Connection fs,String path,Operation op){if(!DocumentStore.digest(fs.read(path,CustomerExportFiles.MAX_BYTES)).equals(op.digest()))throw bad("Die Exportdatei wurde außerhalb dieses Vorgangs verändert.");} + void relocate(DocumentStore.Connection fs,String source,String target,Operation op){ + if(op.missing()){if(fs.exists(source)||fs.exists(target))throw bad("Eine zuvor fehlende Exportdatei ist inzwischen vorhanden.");return;} + if(fs.exists(target)){matching(fs,target,op);if(fs.exists(source)){matching(fs,source,op);fs.remove(source);}} + else{if(!fs.exists(source))throw bad("Die Exportdatei fehlt auch in der Wiederherstellungsablage.");matching(fs,source,op);fs.move(source,target);} + } + Map resume(LegacyUser u,String request){ + token(request);support.transactional("exportfiles","genericdatacontainer");return db.customerExportFileWriteTransaction(()->{long customer=customer(u);var op=find(customer,request);if(op==null)throw bad("Dateivorgang nicht gefunden.");actor(u,customer,op);var s=op.snapshot(); + if(Set.of("REMOVED","RESTORED").contains(op.status()))return fields("completed",true,"status",op.status());String trash=".ng-trash/"+request; + if(op.status().equals("PENDING")){ + if(!snapshot(u,s.file()).equals(s))throw bad("Der Dateieintrag wurde inzwischen verändert.");exclusive(s); + try(var fs=store.open()){fs.mkdir(".ng-trash");relocate(fs,s.physical(),trash,op);} + db.update("DELETE FROM exportfiles WHERE expf_id=? AND cs_id=? AND hq_id=?",s.file(),customer,u.headquartersId());save(op.status("REMOVED")); + support.audit(u,0,customer,0,203,"CUSTOMER_EXPORT_REMOVED="+s.file(),request);return fields("completed",true,"status","REMOVED"); + } + if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_id=? OR expf_cryptname=? OR (hq_id=? AND cs_id=? AND expf_name=?)",s.file(),s.physical(),s.headquarters(),s.customer(),s.name()).isEmpty())throw bad("Dateinummer oder Dateiname ist inzwischen belegt."); + try(var fs=store.open()){relocate(fs,trash,s.physical(),op);}db.insert("exportfiles",s.values());save(op.status("RESTORED"));support.audit(u,0,customer,0,203,"CUSTOMER_EXPORT_RESTORED="+s.file(),request);return fields("completed",true,"status","RESTORED"); + }); + } + Map restore(LegacyUser u,String request){token(request);support.transactional("exportfiles","genericdatacontainer");db.customerExportFileWriteTransaction(()->{long customer=customer(u);var op=find(customer,request);if(op==null)throw bad("Dateivorgang nicht gefunden.");actor(u,customer,op);if(op.status().equals("PENDING"))throw bad("Bitte zuerst das Entfernen fortsetzen.");if(op.status().equals("REMOVED"))save(op.status("RESTORING"));return null;});return resume(u,request);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExportStorage.java b/backend/src/main/java/de/stadtbote/votian/CustomerExportStorage.java new file mode 100644 index 0000000..16f22ea --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExportStorage.java @@ -0,0 +1,118 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.BusinessSupport.*; + +/** Durable export payloads bridge SQL and SFTP without regenerating a changed report after a retry. */ +@RestController +@RequestMapping("/api/customer-portal/export-storage") +class CustomerExportStorage { + record Command(String requestId,CustomerExports.Run run) {} + record Operation(String requestId,long actor,long headquarters,long customer,String scope,String intent, + String name,String physical,String digest,int bytes,int rows,long file,String status,ExportEncryption.Policy encryption) { + Operation completed(long id){return new Operation(requestId,actor,headquarters,customer,scope,intent,name,physical,digest,bytes,rows,id,"DONE",encryption);} + } + final DatabaseService db; + final BusinessSupport support; + final CustomerExports exports; + final CustomerExportFiles files; + final DocumentStore store; + final ExportEncryption encryption; + CustomerExportStorage(DatabaseService db,BusinessSupport support,CustomerExports exports,CustomerExportFiles files,ExportFileStore store){this.db=db;this.support=support;this.exports=exports;this.files=files;this.store=store.store;this.encryption=store.encryption;} + @GetMapping Object operations(Authentication a){return operations(ApiController.user(a));} + @PostMapping Object start(Authentication a,@RequestBody Command c){return start(ApiController.user(a),c);} + @PostMapping("/{requestId}/resume") Object resume(Authentication a,@PathVariable String requestId){return resume(ApiController.user(a),requestId);} + @PostMapping("/{requestId}/cancel") Object cancel(Authentication a,@PathVariable String requestId){return cancel(ApiController.user(a),requestId);} + + CustomerExportData.Scope scope(LegacyUser u){var access=files.access(u);if(!access.root())throw CustomerPortalScope.denied();return exports.data.scope(u);} + static String stamp(CustomerExportData.Scope s){return revision(List.of(s.customer(),s.base(),s.mandator(),new TreeSet<>(s.ownCenters()),new TreeSet<>(s.childCenters())));} + void writable(LegacyUser u){if(!store.configured())throw bad("Die gemeinsame Exportablage ist noch nicht eingerichtet.");encryption.policy(db,u);} + Operation decode(Map row){try{var op=support.json.readValue(text(row,"gdc_content"),Operation.class);token(op.requestId());CustomerExports.filename(op.name());if(!op.physical().equals("ng-"+op.requestId()+(op.encryption()==null?"":op.encryption().suffix()))||op.bytes()<0||op.bytes()>CustomerExportFormat.MAX_OUTPUT||op.rows()<1||op.rows()>10000||!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 gespeicherte Exportvorgang ist unvollständig.");}} + Operation find(long customer,String requestId){var result=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexport' AND gdc_obj_id=? AND gdc_gen_fieldname=?",customer,token(requestId));return result.isEmpty()?null:decode(one(result));} + void actor(LegacyUser u,Operation op,CustomerExportData.Scope s){if(op.actor()!=u.userId()||op.headquarters()!=u.headquartersId()||op.customer()!=s.customer())throw CustomerPortalScope.denied();if(!op.scope().equals(stamp(s)))throw bad("Der freigegebene Kundenumfang hat sich geändert. Der gespeicherte Exportvorgang muss vor einer Fortsetzung geprüft werden.");} + Map operations(LegacyUser u){return db.readTransaction(()->{ + var access=files.access(u);if(!access.root())return fields("canStore",false,"operations",List.of());var s=scope(u); + var pending=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexport' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id DESC LIMIT 101",s.customer()); + if(pending.size()>100)throw bad("Mehr als 100 offene Exportvorgänge. Bitte die Ablage prüfen."); + boolean canStore=true;String reason="";try{writable(u);}catch(org.springframework.web.server.ResponseStatusException e){canStore=false;reason=e.getReason();} + return fields("canStore",canStore,"message",reason,"operations",pending.stream().map(this::decode).filter(op->op.actor()==u.userId()&&op.headquarters()==u.headquartersId()).map(op->fields("requestId",op.requestId(),"name",op.name(),"bytes",op.bytes(),"rows",op.rows(),"canResume",op.scope().equals(stamp(s)))).toList()); + });} + static Object intent(LegacyUser u,Command c){return List.of("CUSTOMER_EXPORT_STORE",u.userId(),u.headquartersId(),c);} + void freeName(LegacyUser u,long customer,String name,String requestId){ + if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE hq_id=? AND cs_id=? AND expf_name=? LIMIT 1",u.headquartersId(),customer,name).isEmpty())throw bad("Dieser Dateiname ist in der Exportablage bereits vorhanden."); + var pending=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexport' AND gdc_obj_id=? AND gdc_context='PENDING' LIMIT 101",customer);if(pending.size()>100)throw bad("Zu viele offene Exportvorgänge."); + for(var p:pending){var op=decode(p);if(op.headquarters()==u.headquartersId()&&op.name().equalsIgnoreCase(name)&&!op.requestId().equals(requestId))throw bad("Ein anderer Exportvorgang verwendet bereits diesen Dateinamen.");} + } + Map start(LegacyUser u,Command c){ + if(c==null||c.run()==null)throw bad("Exportauswahl fehlt."); + return startGenerated(u,c.requestId(),intent(u,c),()->exports.preview(u,c.run(),true),()->unchanged(CustomerExports.version(exports.state(u,c.run().category(),c.run().profile())),c.run().revision())); + } + Map startGenerated(LegacyUser u,String requestId,Object intent,java.util.function.Supplier> generate,Runnable validate){ + token(requestId);String expected=revision(intent); + var previous=db.readTransaction(()->{var s=scope(u);var op=find(s.customer(),requestId);if(op!=null){actor(u,op,s);if(!op.intent().equals(expected))throw bad("Diese Vorgangskennung wurde bereits für einen anderen Export verwendet.");}return op;}); + if(previous==null){ + var generated=generate.get();if(id(generated,"rows")==0)throw bad("Für diese Auswahl gibt es keine Exportdaten.");byte[] plain=text(generated,"content").getBytes(StandardCharsets.UTF_8);if(plain.length>CustomerExportFormat.MAX_OUTPUT)throw bad("Die Exportdatei ist zu groß.");var policy=db.readTransaction(()->{writable(u);return encryption.policy(db,u);});byte[] bytes=encryption.encrypt(plain,policy);String payload=Base64.getEncoder().encodeToString(bytes); + support.transactional("exportfiles","genericdatacontainer"); + db.customerExportFileWriteTransaction(()->{ + var s=scope(u);writable(u);var existing=find(s.customer(),requestId);if(existing!=null){actor(u,existing,s);if(!existing.intent().equals(expected))throw bad("Vorgangskennung bereits anders verwendet.");return null;} + validate.run();encryption.unchanged(db,u,policy); + String name=policy.filename(text(generated,"filename"));freeName(u,s.customer(),name,requestId); + var op=new Operation(requestId, u.userId(),u.headquartersId(),s.customer(),stamp(s),expected,name,"ng-"+requestId+policy.suffix(),DocumentStore.digest(bytes),bytes.length,(int)id(generated,"rows"),0,"PENDING",policy); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngexport","gdc_obj_id",s.customer(),"gdc_gen_fieldname",requestId,"gdc_content",support.encode(op),"gdc_context","PENDING")); + for(int offset=0,part=0;offset13_333_336)throw bad("Ungültige Export-Zwischenspeicherung.");} + byte[] bytes;try{bytes=Base64.getDecoder().decode(value.toString());}catch(IllegalArgumentException e){throw bad("Die gespeicherten Exportdaten sind unvollständig.");} + if(bytes.length!=op.bytes()||!DocumentStore.digest(bytes).equals(op.digest()))throw bad("Die gespeicherten Exportdaten sind unvollständig.");return bytes; + } + Map resume(LegacyUser u,String requestId){ + token(requestId);support.transactional("exportfiles","genericdatacontainer"); + return db.customerExportFileWriteTransaction(()->{ + var s=scope(u);var op=find(s.customer(),requestId);if(op==null)throw bad("Exportvorgang nicht gefunden.");actor(u,op,s); + if(op.status().equals("DONE"))return fields("completed",true,"file",op.file());if(!op.status().equals("PENDING"))throw bad("Unbekannter Exportvorgangsstatus.");writable(u);encryption.unchanged(db,u,op.encryption());freeName(u,s.customer(),op.name(),requestId); + byte[] bytes=payload(op);String target=CustomerExportFiles.physical(op.physical()),stage=".ng-staging/"+requestId+".part"; + // Identity, filename and metadata remain locked while the reserved file is installed. + try(var fs=store.open()){ + if(fs.exists(target)){if(!DocumentStore.digest(fs.read(target,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Der reservierte Dateiname wurde außerhalb dieses Vorgangs verändert.");} + else{fs.mkdir(".ng-staging");if(fs.exists(stage)&&!DocumentStore.digest(fs.read(stage,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))fs.remove(stage);if(!fs.exists(stage))fs.create(stage,bytes);fs.move(stage,target);} + if(fs.exists(stage))fs.remove(stage); + } + long file=db.insert("exportfiles",fields("hq_id",u.headquartersId(),"cs_id",s.customer(),"expf_name",op.name(),"expf_cryptname",target,"expf_storetime",LocalDateTime.now(),"expf_ftpupload",0)); + db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context='DONE' WHERE gdc_obj_type='ngexport' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(op.completed(file)),s.customer(),requestId); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngexpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",s.customer(),requestId+"!_%"); + support.audit(u,0,s.customer(),0,203,"CUSTOMER_EXPORT_FILE="+file+"|ROWS="+op.rows()+"|SHA256="+op.digest(),requestId); + return fields("completed",true,"file",file); + }); + } + Map cancel(LegacyUser u,String requestId){ + token(requestId);support.transactional("exportfiles","genericdatacontainer"); + return db.customerExportFileWriteTransaction(()->{ + var s=scope(u);var op=find(s.customer(),requestId);if(op==null)throw bad("Exportvorgang nicht gefunden."); + if(op.actor()!=u.userId()||op.headquarters()!=u.headquartersId()||op.customer()!=s.customer())throw CustomerPortalScope.denied(); + if(op.status().equals("CANCELLED"))return fields("cancelled",true);if(!op.status().equals("PENDING"))throw bad("Ein abgeschlossener Export kann nicht als offener Vorgang verworfen werden."); + if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=?",op.physical()).isEmpty())throw bad("Die reservierte Datei ist bereits in der Exportablage registriert."); + try(var fs=store.open()){ + if(fs.exists(op.physical())){if(!DocumentStore.digest(fs.read(op.physical(),CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Datei wurde verändert.");fs.remove(op.physical());} + String stage=".ng-staging/"+requestId+".part";if(fs.exists(stage))fs.remove(stage); + } + var cancelled=new Operation(op.requestId(),op.actor(),op.headquarters(),op.customer(),op.scope(),op.intent(),op.name(),op.physical(),op.digest(),op.bytes(),op.rows(),0,"CANCELLED",op.encryption()); + db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context='CANCELLED' WHERE gdc_obj_type='ngexport' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(cancelled),s.customer(),requestId); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngexpart' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",s.customer(),requestId+"!_%"); + support.audit(u,0,s.customer(),0,203,"CUSTOMER_EXPORT_CANCELLED="+requestId,requestId);return fields("cancelled",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerExports.java b/backend/src/main/java/de/stadtbote/votian/CustomerExports.java new file mode 100644 index 0000000..3266379 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerExports.java @@ -0,0 +1,81 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.CustomerExportFormat.*; + +/** Customer export categories 110/115/120 and the existing PHP profile storage. */ +@RestController +@RequestMapping("/api/customer-portal/exports") +class CustomerExports { + record Edit(String requestId,int category,long profile,String revision,String name,String extension,Settings settings) {} + record Run(int category,long profile,String revision,Settings settings,LocalDate from,LocalDate to,String delimiter,boolean headline,String filename) {} + record State(CustomerExportData.Scope scope,List fields,List> profiles,Map profile) {} + final DatabaseService db; + final BusinessSupport support; + final CustomerExportData data; + CustomerExports(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;this.data=new CustomerExportData(db);} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @GetMapping("/{category}/{profile}") Object view(Authentication a,@PathVariable int category,@PathVariable long profile){return view(ApiController.user(a),category,profile);} + @PostMapping("/preview") Object preview(Authentication a,@RequestBody Run r){return preview(ApiController.user(a),r,false);} + @PostMapping("/download") Object download(Authentication a,@RequestBody Run r){return preview(ApiController.user(a),r,true);} + @PostMapping("/save") Object save(Authentication a,@RequestBody Edit e){return save(ApiController.user(a),e);} + + Map options(LegacyUser u){return db.readTransaction(()->{ + data.scope(u);var categories=db.queryForList("SELECT expc_id AS id,expc_name AS label FROM exportcategory WHERE expc_usrtype=2 AND expc_type=0 AND expc_id IN (110,115,120) ORDER BY expc_id"); + if(categories.stream().map(c->id(c,"id")).distinct().count()!=categories.size())throw bad("Exportkategorien sind mehrfach definiert."); + return fields("categories",categories,"from",LocalDate.now().withDayOfMonth(1),"to",LocalDate.now()); + });} + State state(LegacyUser u,int category,long profile){ + var scope=data.scope(u);if(!CustomerExportData.CATEGORIES.contains(category)||profile<0)throw bad("Ungültige Exportkategorie oder Profilnummer."); + if(db.queryForList("SELECT expc_id FROM exportcategory WHERE expc_id=? AND expc_usrtype=2 AND expc_type=0",category).size()!=1)throw CustomerPortalScope.denied(); + var definitions=db.queryForList("SELECT expcf_id,expcf_field,expcf_field_sql,expcf_title FROM exportcategoryfields WHERE expc_id=? ORDER BY expcf_id LIMIT 301",category); + if(definitions.isEmpty()||definitions.size()>300)throw bad("Die Exportfelddefinition ist leer oder zu groß."); + var f=new ArrayList();var ids=new HashSet(); + for(var d:definitions){String expression=text(d,"expcf_field");String key=expression.startsWith("inv.")?expression.substring(4):""; + if(!CustomerExportData.keys(category).contains(key)||!expression.equals(text(d,"expcf_field_sql"))||!ids.add(id(d,"expcf_id")))throw bad("Das Exportfeld „"+text(d,"expcf_title")+"“ benötigt einen aktualisierten Feldabgleich."); + f.add(new Field(id(d,"expcf_id"),key,text(d,"expcf_title"),type(key))); + } + var profiles=db.queryForList("SELECT expp_id AS id,expp_name AS label FROM exportparameters WHERE hq_id=? AND cs_id=? AND expc_id=? ORDER BY expp_name,expp_id LIMIT 1001",u.headquartersId(),scope.customer(),category); + if(profiles.size()>1000)throw bad("Mehr als 1.000 Exportprofile in dieser Kategorie."); + var current=profile==0?Map.of():one(db.queryForList("SELECT * FROM exportparameters WHERE expp_id=? AND hq_id=? AND cs_id=? AND expc_id=?",profile,u.headquartersId(),scope.customer(),category)); + return new State(scope,List.copyOf(f),profiles,current); + } + static Object version(State s){return List.of(s.scope().customer(),s.scope().base(),s.scope().mandator(),new TreeSet<>(s.scope().ownCenters()),new TreeSet<>(s.scope().childCenters()),s.fields(),s.profiles(),s.profile());} + static Settings settings(State s){ + if(s.profile().isEmpty()){var rules=new ArrayList();for(var f:s.fields())rules.add(new Rule(f.id(),null,"l",rules.size(),"",false," ",null,"",null,null,false));return new Settings(rules,"","","","","");} + var p=s.profile();return new Settings(decode(text(p,"expp_parstr")),text(p,"expp_bolchars"),text(p,"expp_eolchars"),text(p,"expp_bofchars"),text(p,"expp_eofchars"),text(p,"expp_headline")); + } + Map view(LegacyUser u,int category,long profile){return db.readTransaction(()->{var s=state(u,category,profile);var settings=settings(s);String warning="";try{validate(settings,s.fields());}catch(org.springframework.web.server.ResponseStatusException ex){warning=ex.getReason();} + return fields("category",category,"profile",profile,"revision",revision(version(s)),"name",text(s.profile(),"expp_name"),"extension",text(s.profile(),"expp_fileextension"),"fields",s.fields(),"profiles",s.profiles(),"settings",settings,"warning",warning,"includesGroupCustomers",!s.scope().childCenters().isEmpty()); + });} + static String filename(String value){if(value==null||!value.matches("[\\p{L}\\p{N}_][\\p{L}\\p{N}_. -]{0,99}")||value.endsWith(".")||value.endsWith(" "))throw bad("Bitte einen Dateinamen ohne Pfadzeichen (höchstens 100 Zeichen) angeben.");return value;} + Map preview(LegacyUser u,Run r,boolean download){return db.readTransaction(()->{ + if(r==null)throw bad("Exportauswahl fehlt.");var s=state(u,r.category(),r.profile());unchanged(version(s),r.revision());validate(r.settings(),s.fields());String name=filename(r.filename()); + var rows=data.rows(u,s.scope(),new CustomerExportData.Query(r.from(),r.to(),r.category())); + var setting=r.settings();String headline=new OrderEntryCatalog(db).parameter(0,"EXPORT_CSV_HEADLINE_CS_"+s.scope().customer());if(!headline.isEmpty())setting=new Settings(setting.rules(),setting.lineStart(),setting.lineEnd(),setting.fileStart(),setting.fileEnd(),headline); + String content=render(setting,s.fields(),rows,r.delimiter(),r.headline());int bytes=content.getBytes(java.nio.charset.StandardCharsets.UTF_8).length; + return fields("filename",name,"rows",rows.size(),"bytes",bytes,"content",download?content:content.substring(0,Math.min(20000,content.length())),"truncated",!download&&content.length()>20000,"revision",revision(List.of(version(s),r,content))); + });} + Map save(LegacyUser u,Edit e){ + if(e==null)throw bad("Exportprofil fehlt.");token(e.requestId());String name=required(ArticleAdministration.clean(e.name(),100,"Profilname"),100,"Profilname"); + String extension=Objects.toString(e.extension(),"");if(!extension.matches("[A-Za-z0-9]{0,10}"))throw bad("Die Dateiendung darf höchstens zehn Buchstaben oder Ziffern enthalten."); + var intent=List.of("CUSTOMER_EXPORT_PROFILE",e);support.transactional("exportparameters","genericdatacontainer"); + return db.customerExportWriteTransaction(()->{ + var s=state(u,e.category(),e.profile());Long done=support.receipt(u,e.requestId(),intent);if(done!=null)return fields("completed",true,"profile",done); + unchanged(version(s),e.revision());validate(e.settings(),s.fields()); + if(!db.queryForList("SELECT expp_id FROM exportparameters WHERE hq_id=? AND cs_id=? AND expc_id=? AND expp_name=? AND expp_id<>?",u.headquartersId(),s.scope().customer(),e.category(),name,e.profile()).isEmpty())throw bad("Dieser Profilname ist bereits vorhanden."); + var setting=e.settings();String encoded=encode(setting.rules());if(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Das Exportprofil ist zu groß für die vorhandene Speicherung."); + var values=fields("expp_name",name,"expp_parstr",encoded,"expp_headline",setting.headline(),"expp_bolchars",setting.lineStart(),"expp_eolchars",setting.lineEnd(),"expp_bofchars",setting.fileStart(),"expp_eofchars",setting.fileEnd(),"expp_fileextension",extension); + long id=e.profile();if(id==0){values.put("hq_id",u.headquartersId());values.put("cs_id",s.scope().customer());values.put("expc_id",e.category());id=db.insert("exportparameters",values);}else{var args=new ArrayList<>(values.values());args.add(id);db.update("UPDATE exportparameters SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE expp_id=?",args.toArray());} + support.audit(u,0,s.scope().customer(),0,203,"CUSTOMER_EXPORT_PROFILE="+id+"|CATEGORY="+e.category(),e.requestId());support.receipt(u,e.requestId(),intent,id);return fields("completed",true,"profile",id); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java b/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java new file mode 100644 index 0000000..e8e706c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerInvoiceAddress.java @@ -0,0 +1,32 @@ +package de.stadtbote.votian; +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. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/invoice-address") +class CustomerInvoiceAddress { + final DatabaseService db;final MasterData masters; + CustomerInvoiceAddress(DatabaseService db,MasterData masters){this.db=db;this.masters=masters;} + @GetMapping Object get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);} + Map view(LegacyUser actor,long customer){actor.require(0);return db.readTransaction(()->{ + var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);var row=masters.load(u,"customers",customer,false); + var centers=db.queryForList("SELECT csc_id,csc_name FROM costcenter WHERE csc_id=? AND cs_id=?",id(row,"csc_id"),customer); + if(centers.size()!=1)return unavailable(0,"","Die Hauptkostenstelle fehlt oder ist diesem Kunden nicht eindeutig zugeordnet."); + long center=id(centers.getFirst(),"csc_id");String label=text(centers.getFirst(),"csc_name"); + var addresses=db.queryForList("SELECT ca.cscad_comp,ca.cscad_comp2,ca.cscad_comp3,ca.cscad_comp4,ca.cscad_hsno,ca.ad_id,a.ad_id AS address_id,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM costcenteraddress ca LEFT JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? AND ca.adt_id=2",center); + if(addresses.size()!=1)return unavailable(center,label,addresses.isEmpty()?"In der Hauptkostenstelle ist keine Rechnungsanschrift hinterlegt.":"In der Hauptkostenstelle sind mehrere Rechnungsanschriften hinterlegt. Bitte die Zuordnung prüfen."); + return comparison(center,label,row,addresses.getFirst()); + });} + static Map unavailable(long center,String label,String warning){return fields("center",center,"label",label,"companyDifferent",false,"addressDifferent",false,"company",List.of(),"address","","warning",warning);} + static Map comparison(long center,String label,Map customer,Map invoice){ + var company=new ArrayList();boolean different=false; + for(String suffix:List.of("","2","3","4")){String value=text(invoice,"cscad_comp"+suffix);company.add(value);different|=!value.equals(text(customer,"cmp_comp"+suffix));} + boolean known=id(invoice,"address_id")>0;boolean address=known&&(id(customer,"ad_id")!=id(invoice,"ad_id")||!text(customer,"cmp_hsno").equals(text(invoice,"cscad_hsno"))); + return fields("center",center,"label",label,"companyDifferent",different,"addressDifferent",address,"company",company,"address",known?CustomerStammblatt.address(invoice,"cscad_hsno"):"","warning",known?"":"Die gespeicherte Rechnungsadresse ist nicht mehr vorhanden. Bitte die Hauptkostenstelle prüfen."); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerMailSettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerMailSettings.java new file mode 100644 index 0000000..04ee0ac --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerMailSettings.java @@ -0,0 +1,93 @@ +package de.stadtbote.votian; + +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; + +/** Customer mail controls from the current customer_special.php; saving never sends mail. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/mail-settings") +class CustomerMailSettings { + static final List BITS=List.of(0,2,3,4,6,7,8,9); + static final List PARAMETERS=List.of("JOBDETAILS_EMAIL_LANGUAGE_","JOB_MAIL_STATION_ARRIVAL_TIME_","JOBDETAILS_EMAIL_NO_FOOTER_"); + static final String JAM="CS_JB_JAM_WAITTIME_AD_HOC_DEFAULT"; + final DatabaseService db; final BusinessSupport support; + CustomerMailSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + record Values(int language,boolean arrivalTime,boolean noFooter,Map extraFields,boolean jamMail,int jamMinutes){} + record Draft(String revision,Values values){} + record Command(String requestId,Draft draft,String fingerprint){} + record View(long customer,String label,String revision,Values values,int jamDefault,List warnings){} + record Preview(String fingerprint,Values before,Values after,List warnings){} + record State(Map owner,List> parameters,View view){} + @GetMapping View get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);} + @PostMapping("/preview") Preview check(Authentication a,@PathVariable long customer,@RequestBody Draft d){return preview(ApiController.user(a),customer,d);} + @PostMapping Map post(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + LegacyUser access(LegacyUser actor){actor.require(0);actor.require(2);var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);u.require(2);return u;} + State state(LegacyUser u,long customer){ + if(customer<=0)throw bad("Ungültiger Kunde."); + var owner=one(db.queryForList("SELECT cs_id,cs_eid,customer.hq_id,customer.cmp_id,cmp_comp,cmp_comp2,cs_jbstatusmail_fields,cs_jb_jam_waittime,cs_jb_jam_waittime_minutes FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE cs_id=? AND customer.hq_id=?",customer,u.headquartersId())); + 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 (?,?,?,?) ORDER BY par_key,hq_id,par_id",PARAMETERS.get(0)+customer,PARAMETERS.get(1)+customer,PARAMETERS.get(2)+customer,JAM); + var customerRows=new HashMap();var defaults=new HashMap(); + for(var row:rows){ + String key=text(row,"par_key");long hq=id(row,"hq_id"); + if(key.equals(JAM)){ + if((hq==0||hq==u.headquartersId())&&id(row,"md_id")==0&&id(row,"emp_id")==0&&defaults.put(hq,text(row,"par_value"))!=null)throw bad("Mehrdeutige Wartezeitvorgabe."); + }else{ + if(id(row,"md_id")!=0||hq!=0||id(row,"emp_id")!=0)throw bad("Abweichender Geltungsbereich der Kunden-Mailvorgaben."); + if(customerRows.put(key,text(row,"par_value"))!=null)throw bad("Mehrdeutige Kunden-Mailvorgaben."); + } + } + String bits=text(owner,"cs_jbstatusmail_fields");if(!bits.matches("[01]{0,100}"))throw bad("Die gespeicherten Mailfelder sind nicht lesbar. Bitte den Bestand klären."); + var extra=new LinkedHashMap();for(int bit:BITS)extra.put(bit,bit();warnings.add("Diese Vorgaben gelten auch für die Altanwendung. Automatische Mails werden weiterhin durch die dort eingerichteten Dienste verarbeitet; das Speichern hier versendet keine Mail."); + warnings.add("Die ausgewählten Inhaltsfelder werden genau so gespeichert. Beim späteren Speichern im PHP können dessen Niederlassungsstandards Rechnungstext und Kundenmail-Bemerkungen wieder aktivieren."); + if(wait<=0)warnings.add("Für das Aktivieren der Vermittlungsstau-Mail fehlt eine positive Standardwartezeit, sofern der Kunde noch keine eigene hat."); + var values=new Values(language,arrival==1,footer==1,Map.copyOf(extra),id(owner,"cs_jb_jam_waittime")>0,(int)id(owner,"cs_jb_jam_waittime_minutes")); + var view=new View(customer,String.join(" · ",List.of(text(owner,"cs_eid"),text(owner,"cmp_comp"),text(owner,"cmp_comp2")).stream().filter(s->!s.isBlank()).toList()),revision(List.of(owner,rows)),values,wait,List.copyOf(warnings)); + return new State(owner,rows,view); + } + static int binary(String value){if(value==null||value.isBlank())return 0;if(!value.equals("0")&&!value.equals("1"))throw bad("Die Kundenvorgabe enthält einen anderen Wert als 0 oder 1. Bitte den Bestand klären.");return Integer.parseInt(value);} + static void validate(Draft d){ + if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}")||d.values()==null)throw bad("Bestandsrevision oder Mailvorgaben fehlen."); + var v=d.values();if(v.language()<0||v.language()>1)throw bad("Bitte Deutsch oder Englisch wählen."); + if(v.extraFields()==null||!v.extraFields().keySet().equals(new HashSet<>(BITS))||v.extraFields().values().stream().anyMatch(Objects::isNull))throw bad("Die Mailfelder sind unvollständig oder unbekannt."); + if(v.jamMinutes()<0||v.jamMinutes()>60||v.jamMinutes()%5!=0)throw bad("Bitte eine Wartezeit von 0 bis 60 Minuten in Fünferschritten wählen."); + } + Preview prepare(LegacyUser u,long customer,Draft d,State s){ + if(!d.revision().equals(s.view().revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Kunden-Mailvorgaben wurden inzwischen geändert. Bitte neu laden."); + if(d.values().jamMail()&&id(s.owner(),"cs_jb_jam_waittime")<=0&&s.view().jamDefault()<=0)throw bad("Für Vermittlungsstau-Mails fehlt eine positive Standardwartezeit."); + return new Preview(revision(List.of(u.userId(),u.originalHeadquartersId(),u.headquartersId(),customer,d)),s.view().values(),d.values(),s.view().warnings()); + } + View view(LegacyUser actor,long customer){return db.readTransaction(()->state(access(actor),customer).view());} + Preview preview(LegacyUser actor,long customer,Draft d){actor.require(0);actor.require(2);validate(d);return db.readTransaction(()->{var u=access(actor);return prepare(u,customer,d,state(u,customer));});} + Map save(LegacyUser actor,long customer,Command c){ + actor.require(0);actor.require(2);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen."); + access(actor); + return db.customerMailSettingsWriteTransaction(()->{ + var u=access(actor);var s=state(u,customer);var receipt=List.of("CUSTOMER_MAIL",customer,c.draft(),c.fingerprint()); + var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true); + var p=prepare(u,customer,c.draft(),s);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Änderungsvorschau passt nicht zu diesen Angaben."); + var v=p.after();boolean changed=!p.before().equals(v); + if(changed){ + String original=text(s.owner(),"cs_jbstatusmail_fields");var bits=new StringBuilder(original+"0".repeat(Math.max(0,10-original.length())));for(int bit:BITS)bits.setCharAt(bit,v.extraFields().get(bit)?'1':'0'); + long wait=v.jamMail()?(id(s.owner(),"cs_jb_jam_waittime")>0?id(s.owner(),"cs_jb_jam_waittime"):s.view().jamDefault()):0; + db.update("UPDATE customer SET cs_jbstatusmail_fields=?,cs_jb_jam_waittime=?,cs_jb_jam_waittime_minutes=? WHERE cs_id=? AND hq_id=?",bits.toString(),wait,v.jamMinutes(),customer,u.headquartersId()); + var vals=List.of(v.language(),v.arrivalTime()?1:0,v.noFooter()?1:0); + for(int i=0;ikey.equals(text(r,"par_key"))).findFirst().orElse(null); + if(old==null){if(!value.equals("0"))db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",""));} + else if(!value.equals(text(old,"par_value")))db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id")); + } + support.audit(u,0,customer,0,203,"Kunden-Mailvorgaben geändert · Sprache, Inhaltsfelder und Versandoptionen",c.requestId()); + } + support.receipt(u,c.requestId(),receipt,changed?1:0);return fields("changed",changed?1:0,"repeated",false); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerMap.java b/backend/src/main/java/de/stadtbote/votian/CustomerMap.java new file mode 100644 index 0000000..d000a26 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerMap.java @@ -0,0 +1,47 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +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; + +/** locating/map.php customer_special: configured vehicles, public phone and today's last location. */ +@RestController +@RequestMapping("/api/customer-portal/map") +class CustomerMap { + final DatabaseService db;final OrderEntryCatalog catalog;final String tiles,attribution; + CustomerMap(DatabaseService db,@Value("${app.fleet-map.tiles:https://tile.openstreetmap.org/{z}/{x}/{y}.png}") String tiles,@Value("${app.fleet-map.attribution:© OpenStreetMap contributors}") String attribution){this.db=db;this.catalog=new OrderEntryCatalog(db);this.tiles=tiles;this.attribution=attribution;} + @GetMapping Object get(Authentication a){return view(ApiController.user(a));} + static List vehicles(String configured){ + if(configured==null||configured.isBlank())return List.of();if(configured.length()>30000)throw bad("Die Fahrzeugfreigabe der Kundenkarte ist zu lang."); + var out=new LinkedHashSet(); + // PHP stores both CSV and an SQL-list fragment such as M047','M048. Never interpolate it into SQL. + for(String entry:configured.split(",",-1)){String sid=entry.strip();if(sid.startsWith("'"))sid=sid.substring(1);if(sid.endsWith("'"))sid=sid.substring(0,sid.length()-1);sid=sid.strip();if(!sid.matches("[A-Za-z0-9+_.-]{1,10}"))throw bad("Ungültige Fahrzeugkennung in der Freigabe der Kundenkarte.");out.add(sid);} + if(out.size()>2000)throw bad("Mehr als 2.000 freigegebene Fahrzeuge für die Kundenkarte.");return List.copyOf(out); + } + Map view(LegacyUser u){return db.readTransaction(()->{ + var context=CustomerPortalScope.context(db,u);long customer=id(context.account(),"cs_id");if(!catalog.parameter(0,"MASK_JB_MAP_VIEW_ENABLED_"+customer).equals("1"))throw CustomerPortalScope.denied(); + var allowed=vehicles(catalog.parameter(0,"MASK_JB_MAP_VIEW_COURIERS_"+customer)); + var hq=one(db.queryForList("SELECT hq_name,hq_gps_lat,hq_gps_long FROM headquarters WHERE hq_id=?",u.headquartersId()));var center=FleetMap.position(hq,"hq_gps_lat","hq_gps_long"); + var now=db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime();long limit=FleetLocation.integer(catalog.parameter(0,"LOCATING_SECONDS_NEEDED_FOR_LBS_LOCATING"),900,1,86400,"Maximales Ortungsalter"); + var items=new ArrayList>(); + if(!allowed.isEmpty()){ + var args=new ArrayList(allowed);args.add(mandator(db,u));args.add(now.toLocalDate().atStartOfDay());args.add(now); + var rows=db.queryForList("SELECT cr.cr_sid,cr.vht_id,cr.cr_gps_time,cr.cr_gps_type,cr.cr_gps_lat,cr.cr_gps_long,u.usr_phone,u.usr_phone2 FROM courier cr JOIN user u ON u.usr_id=cr.usr_id AND u.hq_id=cr.hq_id JOIN headquarters h ON h.hq_id=cr.hq_id WHERE cr.cr_sid IN ("+CourierPortal.placeholders(allowed.size())+") AND cr.hq_id IN ("+CourierBourse.HQ_SQL+") AND COALESCE(h.hq_disabled,0)=0 AND cr.cr_gps_time>? AND cr.cr_gps_time<=? ORDER BY cr.cr_sid LIMIT 2001",args.toArray()); + if(rows.size()>2000)throw bad("Zu viele aktuelle Fahrzeugpositionen für die Kundenkarte."); + var types=new HashMap();for(var t:db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='vehicletype' AND COALESCE(mt_objtype,'')='' AND COALESCE(mt_objid,0)=0 ORDER BY mt_id"))types.putIfAbsent(id(t,"mt_sort"),text(t,"mt_value")); + var unique=new HashSet();for(var row:rows){String sid=text(row,"cr_sid");if(!unique.add(sid.toUpperCase(Locale.ROOT)))throw bad("Eine freigegebene Fahrzeugkennung ist mehrfach angemeldet. Bitte die Zuordnung prüfen."); + var at=dateTime(row,"cr_gps_time");long age=Duration.between(at,now).getSeconds(),kind=id(row,"cr_gps_type");var point=FleetMap.position(row,"cr_gps_lat","cr_gps_long");String source=switch((int)kind){case 0->"Unbestimmt";case 1->"LBS";case 2->"GPS";case 3->"Netzwerk";case 9->"Ortung aus";default->"Unbekannt";}; + String warning="";if(!Set.of(0L,1L,2L,3L).contains(kind)){point=null;warning=kind==9?"Ortung ausgeschaltet":"Unbekannte Ortungsquelle";}else if(point==null)warning="Keine gültige Kartenposition";else if(age>limit)warning="Letzte Ortung ist veraltet"; + String phone=text(row,"usr_phone");if(phone.isBlank())phone=text(row,"usr_phone2"); + items.add(fields("sid",sid,"vehicle",types.getOrDefault(id(row,"vht_id"),"Fahrzeugtyp "+id(row,"vht_id")),"phone",phone,"locatedAt",at.toString(),"source",source,"ageSeconds",age,"stale",age>limit,"point",point,"warning",warning)); + } + } + return fields("headquarters",text(hq,"hq_name"),"center",center,"at",now.toString(),"tiles",tiles,"attribution",attribution,"items",items,"configured",allowed.size(),"message",allowed.isEmpty()?"Für diesen Kunden sind noch keine Fahrzeuge für die Kartenübersicht freigegeben.":items.isEmpty()?"Für die freigegebenen Fahrzeuge liegen heute keine Ortungen vor.":""); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java new file mode 100644 index 0000000..46b007b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderDocument.java @@ -0,0 +1,24 @@ +package de.stadtbote.votian; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderCustomerMailContent.*; + +/** A customer receipt assembled only from the authorized public projection. */ +final class CustomerOrderDocument { + @SuppressWarnings("unchecked") static List> rows(Map m,String key){return (List>)m.getOrDefault(key,List.of());} + static String row(String label,String value){return value.isBlank()?"":""+escape(label)+""+escape(value).replace("\n","
")+"";} + static String html(Map detail,Map evidence){ + boolean pending=Boolean.TRUE.equals(evidence.get("pricePending")); + var out=new StringBuilder("

Auftragsdaten

");out.append(row("Auftragszeit",time(detail,"ordered"))).append(row("Erledigung",time(detail,"finished"))).append(row("Kurierfahrzeug",text(detail,"vehicle")));if(id(detail,"cancelled")!=0)out.append(row("Stornokennzeichen",text(detail,"cancelled")));for(var f:rows(evidence,"summary"))out.append(row(text(f,"label"),text(f,"value")));out.append("
"); + 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(!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){ + var articles=rows(table,"rows");if(articles.isEmpty())return;var columns=rows(table,"columns");out.append("

Artikel

");for(var col:columns)out.append("");out.append(""); + for(var article:articles){out.append("");var cells=(List)article.get("cells");for(int i=0;i").append(escape(value)).append("");}out.append("");}out.append("
").append(escape(col.get("label"))).append("
"); + } + private CustomerOrderDocument(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java new file mode 100644 index 0000000..852b72e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEdit.java @@ -0,0 +1,143 @@ +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.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** jobs2 edits an existing customer order, with stable station identities and a fresh price preview. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{job}/edit") +class CustomerOrderEdit { + record Command(String revision,CustomerOrderEntry.Draft draft,List stationIds,String fingerprint,boolean confirmed){} + record State(CustomerOrderTexts.State order,CustomerEntryAccess.State access,List> metadata,List> stationMetadata,List> prices,List> details,List> payments,List> articles,List> children,List> disposition,List> rankings,List> stockMoves,List> settings,long externalCenter,List> externalStations){} + record Prepared(State state,Request request,OrderEntryService.Prepared entry){} + final DatabaseService db;final BusinessSupport support;final OrderEntryService entries;final OrderRoutingService routing;final LegacyOrderPricing pricing;final CustomerEntryAccess access;final CustomerOrderTexts texts; + CustomerOrderEdit(DatabaseService db,BusinessSupport support,OrderEntryService entries,OrderRoutingService routing,LegacyOrderPricing pricing){this.db=db;this.support=support;this.entries=entries;this.routing=routing;this.pricing=pricing;access=new CustomerEntryAccess(db,entries.customerAccess().clock);texts=new CustomerOrderTexts(db,support);} + @GetMapping Object get(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);} + @PostMapping("/preview") Object preview(Authentication a,@PathVariable long job,@RequestBody Command c){return preview(ApiController.user(a),job,c);} + @PostMapping Object post(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);} + State state(LegacyUser u,long job){ + var order=texts.state(u,job);var scope=access.state(u);var external=new OrderExternalStations(db).policy(u); + var externalStations=external.mode().equals("1")?db.queryForList("SELECT csc_id,csc_is_extern,csc_visible FROM costcenter WHERE csc_id IN (SELECT csc_id FROM tour WHERE jb_id=?) AND csc_is_extern=1 ORDER BY csc_id",job):List.>of(); + return new State(order,scope,db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? ORDER BY gdc_id",job),db.queryForList("SELECT g.* FROM genericdatacontainer g JOIN tour t ON t.tr_id=g.gdc_obj_id WHERE g.gdc_obj_type='tr' AND t.jb_id=? ORDER BY g.gdc_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 jobprice WHERE jb_id=? ORDER BY mt_sort",job),db.queryForList("SELECT * FROM jobpayment WHERE jb_id=?",job),db.queryForList("SELECT * FROM tourarticle WHERE jb_id=? ORDER BY trat_id",job),db.queryForList("SELECT jb_id FROM job WHERE jb_id_parent=? ORDER BY jb_id",job),db.queryForList("SELECT * FROM vehicledisposition WHERE jb_id=?",job),db.queryForList("SELECT * FROM autoranking WHERE jb_id=? ORDER BY cr_id",job),db.queryForList("SELECT stkmv_id FROM stockmove WHERE jb_id=? ORDER BY stkmv_id",job),db.queryForList("SELECT par_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) ORDER BY par_id",u.headquartersId()),external.center(),externalStations); + } + String blocked(State s){ + String reason=texts.blocked(s.order());if(!reason.isEmpty())return reason; + var j=s.order().job(); + if(s.order().stops().stream().anyMatch(t->!text(t,"tr_sign").isBlank()))return "Stationen mit Unterschriften dürfen nicht verändert werden."; + if(id(j,"cr_id")>0||id(j,"jb_id_parent")!=0||!s.children().isEmpty()||!s.payments().isEmpty()||!s.articles().isEmpty()||!s.stockMoves().isEmpty()||!s.disposition().isEmpty())return "Verbundene Unternehmer-, Einnahmen-, Artikel- oder Fahrzeugplanungsbuchungen werden durch die Zentrale gemeinsam geändert."; + if(id(j,"jb_permanent")!=0||id(j,"jb_offer")!=0||id(j,"jb_globaljob")!=0)return "Serien und übergreifende Angebote werden durch die Zentrale bearbeitet."; + if(s.metadata().stream().anyMatch(m->Set.of("ng_photo_selection","ng_insurance","jb_id_insurance","ng_courier_children","jb_toll").contains(text(m,"gdc_gen_fieldname"))))return "Für diesen Auftrag bestehen zusätzliche Foto-, Versicherungs- oder Kostenbuchungen. Bitte die Zentrale mit der gemeinsamen Änderung beauftragen."; + if(s.order().stops().stream().anyMatch(t->id(t,"csc_id")>0&&id(t,"csc_id")!=s.externalCenter()&&s.externalStations().stream().noneMatch(c->id(c,"csc_id")==id(t,"csc_id"))&&!s.access().centers().contains(id(t,"csc_id"))))return "Eine Stationskostenstelle liegt außerhalb dieses Kundenzugangs."; + if(!text(j,"jb_freetext_2").isBlank())return "Leistungskalkulationen werden durch die Zentrale gemeinsam mit der Strecke geändert."; + if(s.order().stops().stream().map(t->id(t,"tr_id")).distinct().count()!=s.order().stops().size()||s.order().stops().stream().map(t->id(t,"tr_sort")).distinct().count()!=s.order().stops().size())return "Die Stationsfolge ist nicht eindeutig. Bitte die Zentrale verständigen."; + if(s.access().vehicleTypes().stream().noneMatch(v->id(v,"id")==id(j,"vht_id")))return "Der bisherige Fahrzeugtyp ist für diesen Kundenzugang nicht mehr freigegeben."; + if(id(j,"cr_id_order")>0&&s.access().vehicles().stream().noneMatch(v->text(v,"label").equals(text(j,"cr_sid"))))return "Der vorgemerkte Kurier ist in diesem Kundenzugang nicht mehr auswählbar."; + return ""; + } + Map options(LegacyUser u,long job){return db.readTransaction(()->{ + var s=state(u,job);String reason=blocked(s);if(!reason.isEmpty())return fields("id",job,"canEdit",false,"reason",reason); + var j=s.order().job();var a=s.access();var stops=new CustomerEntryTours(db).source(u,a,job).stops().stream().map(t->new Station(t.costCenterId(),t.type(),t.name(),t.street(),t.houseNumber(),t.postalCode(),t.city(),t.country(),t.contact(),t.phone(),a.remarks()?CustomerOrderTexts.visible(withoutDerived(t.note())):"",t.reference(),null,null,List.of())).toList(); + // Copy-mode preferences must never silently change an existing order's courier or date. + Long vehicle=a.vehicles().stream().filter(v->text(v,"label").equals(text(j,"cr_sid"))&&id(v,"type")==id(j,"vht_id")).map(v->id(v,"id")).findFirst().orElse(null); + int cash=0;for(int n=0;n0)throw bad("Mehrere Kassierstationen im Auftrag. Bitte die Zentrale verständigen.");cash=n+1;} + var filters=CustomerPortal.parts(text(j,"jb_cr_filter")+","+text(j,"jb_cr_filter_opt")).stream().filter(f->a.filters().stream().anyMatch(r->text(r,"code").equals(f))).distinct().toList(); + var draft=fields("acceptUntilTime",CustomerEntryAcceptance.load(j,a.acceptanceEnabled()),"revision",a.revision(),"mode",id(j,"jb_reserv")==1?Mode.RESERVATION:Mode.IMMEDIATE,"pickupAt",id(j,"jb_reserv")==1?dateTime(j,"jb_ordertime"):null,"payerCostCenterId",CustomerEntryTours.payer(j),"vehicleTypeId",id(j,"vht_id"),"courierVehicleId",vehicle,"stops",stops,"reference",text(j,"jb_commission_no"),"notes",CustomerOrderTexts.visible(text(j,"jb_freetext_1")),"filters",filters,"requestId",UUID.randomUUID().toString(),"payment",id(j,"jb_payment")==2?Payment.CASH:Payment.INVOICE,"cashStop",cash==0?null:cash,"costSplit",id(j,"jb_payment")==1); + for(var key:Map.of("weight","jb_weight","length","jb_crvh_length","width","jb_crvh_width","height","jb_crvh_height","positions","jb_crvh_position").entrySet())draft.put(key.getKey(),a.dimensions()?decimal(j,key.getValue()):java.math.BigDecimal.ZERO); + var options=new CustomerOrderEntry(db,entries).options(u);options.put("orderer",text(j,"jb_orderer")); + if(Boolean.TRUE.equals(options.get("tracking")))draft.put("trackingEmails",new CustomerEntryTracking(db).load(s.order().stops().stream().map(t->id(t,"tr_id")).toList())); + return fields("id",job,"canEdit",true,"reason","","revision",revision(s),"options",options,"draft",draft,"stationIds",s.order().stops().stream().map(t->id(t,"tr_id")).toList(),"ordered",dateTime(j,"jb_ordertime")); + });} + Prepared prepare(LegacyUser u,long job,Command c){ + if(c==null||c.draft()==null)throw bad("Änderungsdaten fehlen.");var d=c.draft();BusinessSupport.token(d.requestId());var s=state(u,job);unchanged(s,c.revision());String reason=blocked(s);if(!reason.isEmpty())throw bad(reason); + if(d.sourceOrderId()!=null||d.sourceRevision()!=null||d.quoteFingerprint()!=null)throw bad("Beim Bearbeiten darf keine andere Tour übernommen werden."); + if(d.stops()==null||c.stationIds()==null||c.stationIds().size()!=d.stops().size())throw bad("Die Stationszuordnung fehlt."); + var seen=new HashSet();var existing=s.order().stops().stream().map(t->id(t,"tr_id")).toList(); + for(Long key:c.stationIds())if(key==null||key<0||key>0&&(!existing.contains(key)||!seen.add(key)))throw bad("Die Stationszuordnung ist ungültig."); + for(var t:s.order().stops())if(!seen.contains(id(t,"tr_id"))&&(!s.access().remarks()&&!text(t,"tr_remark").isBlank()||!text(t,"tr_cs_freetext").isBlank()||s.stationMetadata().stream().anyMatch(m->id(m,"gdc_obj_id")==id(t,"tr_id"))||!CustomerOrderTexts.visible(withoutDerived(withoutCash(text(t,"tr_remark")))).equals(withoutDerived(withoutCash(text(t,"tr_remark"))))))throw bad("Eine Station mit zusätzlichen Vermerken oder Nachweisen darf nicht entfernt werden."); + CustomerOrderTexts.input(d.reference(),50,s.order().referenceRequired(),"Kommissionsnummer");CustomerOrderTexts.input(d.notes(),4000,false,"Auftragstext");for(var t:d.stops()){if(t==null)throw bad("Eine Station fehlt.");CustomerOrderTexts.input(t.note(),2000,false,"Stationsbemerkung");} + var source=new CustomerEntryTours(db).source(u,s.access(),job);var node=(com.fasterxml.jackson.databind.node.ObjectNode)CustomerEntryAccess.JSON.valueToTree(d);node.put("sourceOrderId",job);node.put("sourceRevision",source.revision()); + Request request=access.command(u,CustomerEntryAccess.JSON.convertValue(node,CustomerOrderEntry.Draft.class)); + if(!s.access().dimensions()){ + var preserved=(com.fasterxml.jackson.databind.node.ObjectNode)CustomerEntryAccess.JSON.valueToTree(request); + for(var field:Map.of("weight","jb_weight","length","jb_crvh_length","width","jb_crvh_width","height","jb_crvh_height","positions","jb_crvh_position").entrySet())preserved.set(field.getKey(),CustomerEntryAccess.JSON.valueToTree(LegacyOrderPricing.nz(decimal(s.order().job(),field.getValue())))); + request=CustomerEntryAccess.JSON.convertValue(preserved,Request.class); + } + entries.validateFields(u,request);request=entries.automaticPricing(u,request);var p=entries.prepare(u,request,dateTime(s.order().job(),"jb_ordertime")); + if(request.mode()==Mode.IMMEDIATE)p=new OrderEntryService.Prepared(p.payer(),p.headquarters(),p.vehicle(),dateTime(s.order().job(),"jb_ordertime"),p.now(),p.cashPayer(),p.dispatchHq(),p.favoured(),p.photoCustomer()); + if(p.at()==null)throw bad("Die ursprüngliche Auftragszeit fehlt.");return new Prepared(s,request,p); + } + Quote calculate(LegacyUser u,Prepared p,OrderRoutingService.Result route){ + var q=new OrderEntryAccounting(db,access.catalog).quote(u,p.request(),pricing.calculateExisting(u,p.request(),p.entry().payer(),p.entry().at(),route,null),List.of()); + q=CustomerEntryAcceptance.quote(p.request(),q); + q=OrderMediationDeadline.apply(q,new OrderMediationDeadline(db).plan(u,p.request(),p.entry(),false)); + if(id(p.state().order().job(),"jb_longhaul")!=0&&q.route()==null)throw bad("Für diesen Fernauftrag fehlt eine aktuelle Streckenberechnung. Bitte die Zentrale verständigen."); + if(q.courierLimitExceeded())throw bad("Die berechnete Frachtvergütung überschreitet die Freigabegrenze. Bitte die Zentrale verständigen.");return q; + } + static String fingerprint(Command c,Quote q){return revision(List.of(c.revision(),c.draft(),c.stationIds(),q.fingerprint()));} + Map preview(LegacyUser u,long job,Command c){ + var p=db.readTransaction(()->prepare(u,job,c));entries.validateAddresses(p.request());var route=routing.resolve(u,p.request(),p.entry().dispatchHq()); + return db.readTransaction(()->{var current=prepare(u,job,c);var q=calculate(u,current,route);boolean show=current.state().access().showPrice()&&!q.pricePending();return fields("pricePending",q.pricePending(),"acceptUntil",current.request().acceptUntil(),"trackingEmails",c.draft().trackingEmails(),"id",job,"fingerprint",fingerprint(c,q),"payment",c.draft().payment(),"cashStop",c.draft().cashStop(),"costSplit",c.draft().costSplit(),"showPrice",show,"net",show?q.net():null,"tax",show?q.tax():null,"gross",show?q.gross():null,"previousNet",show?decimal(current.state().order().job(),"jb_totalprice"):null,"stations",c.draft().stops().size(),"batch",q.batch(),"allocations",q.costAllocations()==null?List.of():q.costAllocations().stream().map(a->fields("costCenterId",a.costCenterId(),"name",a.name(),"net",show?a.net():null,"tax",show?a.tax():null,"gross",show?a.gross():null)).toList(),"message",q.pricePending()?CustomerPendingPrice.MESSAGE:"Die Änderungen werden unter derselben Auftragsnummer gespeichert.","ordered",current.entry().at());}); + } + Map replay(LegacyUser u,long job,Command c){var s=state(u,job);if(!s.order().enabled())throw CustomerPortalScope.denied();new CustomerEntryTracking(db).validate(s.access(),c.draft());Long prior=support.receipt(u,c.draft().requestId(),List.of("CUSTOMER_ORDER_EDIT",job,c));return prior==null?null:result(job,true);} + static Map result(long job,boolean replay){return fields("id",job,"number",Long.toString(job),"replayed",replay,"photoSyncPending",false);} + Map save(LegacyUser u,long job,Command c){ + if(c==null||c.draft()==null||!c.confirmed())throw bad("Bitte die Auftragsänderungen bestätigen.");BusinessSupport.token(c.draft().requestId());var previous=db.readTransaction(()->replay(u,job,c));if(previous!=null)return previous; + var before=db.readTransaction(()->prepare(u,job,c));entries.validateAddresses(before.request());var route=routing.resolve(u,before.request(),before.entry().dispatchHq()); + support.transactional("job","tour","address","tourservice","jobprice","genericdatacontainer","courier","autoranking"); + return db.customerOrderEditWriteTransaction(()->{var prior=replay(u,job,c);if(prior!=null)return prior;var current=prepare(u,job,c);var q=calculate(u,current,route);if(!fingerprint(c,q).equals(c.fingerprint()))throw OrderEntryService.conflict("Die Preisvorschau hat sich geändert. Bitte erneut prüfen."); + write(u,job,c,current,q);support.audit(u,job,current.state().order().customer(),0,163,"Kundenauftrag geändert: Strecke, Termin und Preis geprüft | CUSTOMER_ORDER_EDIT",c.draft().requestId());support.receipt(u,c.draft().requestId(),List.of("CUSTOMER_ORDER_EDIT",job,c),job);return result(job,false); + }); + } + 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); + 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(";"))); + 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;nid(v,"tr_id")==key).findFirst().orElse(Map.of()); + var addresses=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",t.street(),t.postalCode(),t.city(),t.country());long address=addresses.isEmpty()?db.insert("address",fields("ad_street",t.street(),"ad_zipcode",t.postalCode(),"ad_city",t.city(),"ad_country",t.country())):id(addresses.getFirst(),"ad_id"); + String raw=withoutDerived(withoutCash(text(original,"tr_remark")));String note=a.remarks()?CustomerOrderTexts.replace(raw,Objects.toString(t.note(),""),10000,"Stationsbemerkung"):raw; + if(n==0){String generated=derived(u,values,q);if(!generated.isBlank())note+="\n[[\n"+generated+"]]";} + if(r.payment()==Payment.CASH&&Objects.equals(r.cashStop(),n+1))note+=CustomerPendingPrice.cashRemark(q,r.toll()); + if(note.length()>10000)throw bad("Die Stationsbemerkung ist mit den internen Vermerken zu lang."); + long stationCenter=t.costCenterId()==null?p.state().externalCenter():t.costCenterId(); + if(t.costCenterId()==null&&external.policy().mode().equals("1"))stationCenter=p.state().externalStations().stream().anyMatch(v->id(v,"csc_id")==id(original,"csc_id"))?id(original,"csc_id"):new OrderExternalAddresses(db).create(u.headquartersId(),external.customers().get(n),address,t); + var stop=fields("ad_id",address,"tr_sort",n+1,"csc_id",stationCenter,"tr_comp",t.name(),"tr_hsno",t.houseNumber(),"tr_person",Objects.toString(t.contact(),""),"tr_phone",Objects.toString(t.phone(),""),"tr_remark",note,"tr_commission_no",Objects.toString(t.reference(),""),"tr_ware_from_to",switch(t.type()){case NONE->0;case PICKUP->2;case DELIVERY->1;case BOTH->3;},"tr_mediationarea_id",LegacyAddressTariffs.postal(db,u.headquartersId(),t)); + long savedKey=key;if(key>0)BusinessSupport.update(db,"tour","tr_id",key,stop);else {stop.put("jb_id",job);stop.put("tr_cs_freetext","");savedKey=db.insert("tour",stop);} + if(trackingEmails!=null)tracking.save(savedKey,trackingEmails.get(n)); + } + for(var t:p.state().order().stops())if(!c.stationIds().contains(id(t,"tr_id")))db.update("DELETE FROM tour WHERE tr_id=? AND jb_id=?",id(t,"tr_id"),job); + BusinessSupport.update(db,"job","jb_id",job,values); + if(trackingEmails!=null)tracking.hint(job,access.catalog.enabled(u.headquartersId(),"MASK_CR_PRICE_MODE")); + boolean routeChanged=!p.state().order().stops().stream().map(t->List.of(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"))).toList().equals(CustomerEntryTours.basis(r.stops())); + db.update("DELETE FROM tourservice WHERE jb_id=?",job);db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort BETWEEN 1 AND 10 OR mt_sort IN (13,15))",job); + if(routeChanged||q.route()!=null)db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort=11",job); + if(routeChanged||id(old,"vht_id")!=r.vehicleTypeId()||LegacyOrderPricing.nz(decimal(old,"jb_weight")).compareTo(LegacyOrderPricing.nz(decimal(values,"jb_weight")))!=0){db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort IN (12,14)",job);if(p.state().details().stream().anyMatch(v->id(v,"mt_sort")==12))db.update("UPDATE job SET jb_incomplete=1 WHERE jb_id=?",job);} + + for(String key:List.of("jb_cr_subtotal_last_without_service","jb_cr_price_retour","ng_cost_split","ng_route_calculation","ng_calculation_input","ng_calculation_result","longhaul_deselected","jb_job_jam","jb_invtext_dirty"))db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,key); + entries.writePricing(u,r,e,q,job);new OrderMediationDeadline(db).persist(job,q.mediationDeadline()); + db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context","CUSTOMER_EDIT")); + if(CustomerEntryTours.payer(old)!=r.payerCostCenterId()){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_payment_term','ng_payment_term_label')",job);OrderPaymentTerms.capture(db,job,e.photoCustomer());} + db.update("DELETE FROM autoranking WHERE jb_id=?",job); + if(status==0&&!e.vehicle().isEmpty())db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=? AND hq_id=?",id(e.vehicle(),"cr_id"),u.headquartersId()); + } + String derived(LegacyUser u,Map values,Quote q){ + StringBuilder out=new StringBuilder();if(access.catalog.enabled(u.headquartersId(),"MASK_JB_CR_PRICE_2_TR_REMARK")&&access.catalog.enabled(u.headquartersId(),"MASK_CR_PRICE_MODE"))out.append("Frachtverguetung: ").append(q.courierNet()).append(" EUR\n"); + if(access.catalog.enabled(u.headquartersId(),"MASK_JB_MEASURES_2_TR_REMARK"))for(var item:List.of(List.of("jb_weight","Maximalgewicht","kg"),List.of("jb_crvh_length","Länge","cm"),List.of("jb_crvh_width","Breite","cm"),List.of("jb_crvh_height","Höhe","cm"),List.of("jb_crvh_position","Plätze","")))if(decimal(values,item.get(0))!=null&&decimal(values,item.get(0)).signum()>0)out.append(item.get(1)).append(": ").append(decimal(values,item.get(0))).append(" ").append(item.get(2)).append("\n");return out.toString(); + } + static String withoutDerived(String value){return Objects.toString(value,"").replace(CustomerEntryTracking.GPS_HINT,"").replaceAll("(?m)^(?:Frachtverguetung|Frachtvergütung|Maximalgewicht|Länge|Breite|Höhe|Plätze): [^\\r\\n]*(?:\\r?\\n|$)","").replaceAll("\\[\\[\\s*\\]\\]","").strip();} + static String withoutCash(String value){return value.replace(CustomerPendingPrice.CASH,"").replace("*** ZAHLT BAR ***","").replaceAll("(?m)\\*\\*\\* Bitte im Auftrag STADTBOTE Bar [^\\r\\n]* kassieren und quittieren\\. \\*\\*\\*","").strip();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderEntry.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEntry.java new file mode 100644 index 0000000..25c3592 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEntry.java @@ -0,0 +1,35 @@ +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.BigDecimal;import java.time.LocalDateTime;import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +@RestController +@RequestMapping("/api/customer-portal/entry") +class CustomerOrderEntry { + record Draft(String revision,Mode mode,LocalDateTime pickupAt,long payerCostCenterId,int vehicleTypeId,Long courierVehicleId,List stops,String reference,String notes,BigDecimal weight,BigDecimal length,BigDecimal width,BigDecimal height,BigDecimal positions,List filters,String requestId,String quoteFingerprint,Long sourceOrderId,String sourceRevision,Payment payment,Integer cashStop,boolean costSplit,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) List trackingEmails,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) String acceptUntilTime){Draft{payment=payment==null?Payment.INVOICE:payment;if(acceptUntilTime!=null&&acceptUntilTime.isBlank())acceptUntilTime=null;}} + final DatabaseService db;final OrderEntryService entries;final CustomerEntryAccess access; + CustomerOrderEntry(DatabaseService db,OrderEntryService entries){this.db=db;this.entries=entries;access=new CustomerEntryAccess(db,entries.customerAccess().clock);} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @GetMapping("/addresses/{center}") Object addresses(Authentication a,@PathVariable long center){return addresses(ApiController.user(a),center);} + @PostMapping("/quote") Object quote(Authentication a,@RequestBody Draft draft){return quote(ApiController.user(a),draft);} + @PostMapping Object create(Authentication a,@RequestBody Draft draft){return create(ApiController.user(a),draft);} + Map options(LegacyUser u){return db.readTransaction(()->{ + var s=access.state(u);var payer=access.catalog.costCenter(u,s.payer(),true); + return fields("acceptanceEnabled",s.acceptanceEnabled(),"tracking",new CustomerEntryTracking(db).enabled(s.customer()),"cashAvailable",s.payerSelectable()&&s.cashPayer()>0,"cashPayerSelect",s.cashPayerSelect(),"splitAvailable",s.payerSelectable()&&!s.cashPayerSelect(),"canRead",CustomerPortalScope.context(db,u).can(4),"revision",s.revision(),"base",s.base(),"payer",s.payer(),"payerLabel",text(payer,"csc_name"),"orderer",s.orderer(),"centers",s.centerRows(),"vehicles",s.vehicles(),"filters",s.filters(),"payerSelectable",s.payerSelectable(),"remarks",s.remarks(),"countryLocked",s.countryLocked(),"dimensions",s.dimensions(),"showPrice",s.showPrice(),"referenceRequired",id(s.customerRow(),"cs_jb_commission_no")==1&&access.catalog.enabled(u.headquartersId(),"MASK_COMMISSION_NO_MANDATORY"),"vehicleTypes",s.vehicleTypes(),"defaultVehicleType",s.defaultVehicleType(),"dimensionsRequired",s.dimensionsRequired()); + });} + List> addresses(LegacyUser u,long center){return db.readTransaction(()->{ + var s=access.state(u);if(!s.centers().contains(center))throw CustomerPortalScope.denied(); + return db.queryForList("SELECT ca.cscad_comp AS name,a.ad_street AS street,ca.cscad_hsno AS houseNumber,a.ad_zipcode AS postalCode,a.ad_city AS city,CASE WHEN a.ad_country='D' THEN 'DE' ELSE a.ad_country END AS country,ca.cscad_person AS contact,ca.cscad_phone AS phone,ca.cscad_remark AS note FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? AND ca.adt_id=4",center).stream().map(r->{var row=new LinkedHashMap();for(String key:List.of("name","street","houseNumber","postalCode","city","country","contact","phone"))row.put(key,text(r,key));row.put("costCenterId",center);row.put("note",s.remarks()?OrderDeliveryData.publicRemark(text(r,"note")):"");return (Map)row;}).toList(); + });} + Map quote(LegacyUser u,Draft d){ + Quote q=entries.customerQuote(u,d);return db.readTransaction(()->{var s=access.state(u);if(!s.revision().equals(d.revision()))throw OrderEntryService.conflict("Die Kundenvorgaben wurden geändert. Bitte neu laden."); + boolean show=s.showPrice()&&!q.pricePending(); + return fields("pricePending",q.pricePending(),"acceptUntil",CustomerEntryAcceptance.resolve(d.acceptUntilTime(),s.acceptanceEnabled(),access.clock),"photoRequirements",new CustomerEntryPhotos(db).plan(s,d),"trackingEmails",d.trackingEmails(),"payment",d.payment(),"cashStop",d.cashStop(),"costSplit",d.costSplit(),"allocations",q.costAllocations()==null?List.of():q.costAllocations().stream().map(c->fields("costCenterId",c.costCenterId(),"name",c.name(),"net",show?c.net():null,"tax",show?c.tax():null,"gross",show?c.gross():null)).toList(),"fingerprint",q.fingerprint(),"showPrice",show,"net",show?q.net():null,"tax",show?q.tax():null,"gross",show?q.gross():null,"batch",q.batch(),"stations",d.stops().size(),"message",q.pricePending()?CustomerPendingPrice.MESSAGE:q.batch()?"Die Abrechnung erfolgt nach der hinterlegten Kundenliste.":show?"Bitte den Auftrag und den berechneten Preis prüfen.":"Der Auftrag ist geprüft. Die Preisermittlung erfolgt nach den hinterlegten Kundenvorgaben.");}); + } + Created create(LegacyUser u,Draft d){return entries.customerCreate(u,d);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderEvidence.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEvidence.java new file mode 100644 index 0000000..ce88c5b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderEvidence.java @@ -0,0 +1,67 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; +import java.math.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Public customer detail evidence; no courier notes, tracking tokens or internal log messages. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{order}") +class CustomerOrderEvidence { + final DatabaseService db;final CustomerPortal portal;final OrderDeliveryData data; + CustomerOrderEvidence(DatabaseService db,CustomerPortal portal,BusinessSupport support){this.db=db;this.portal=portal;this.data=new OrderDeliveryData(db,support.historySchema);} + @GetMapping("/evidence") Object view(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);} + @GetMapping("/stations/{station}/photos/{key}") Object photo(Authentication a,@PathVariable long order,@PathVariable long station,@PathVariable String key){return photo(ApiController.user(a),order,station,key);} + @GetMapping("/pdf") ResponseEntity 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-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;} + String unique(String sql,String column,Object...args){var rows=db.queryForList(sql,args);if(rows.size()>1)throw bad("Eine Zuordnung im Kundenauftrag ist mehrdeutig.");return rows.isEmpty()?"":text(rows.getFirst(),column);} + boolean ambiguous(long order,long station){return !db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND jb_id<>? LIMIT 1",station,order).isEmpty();} + List pictures(long order,long station){var photos=data.pictures(station);if(ambiguous(order,station)&&photos.stream().anyMatch(p->p.key().startsWith("b2b-")))throw bad("B2B-Fotos sind wegen einer mehrdeutigen Stations-/Auftragsnummer gesperrt.");return photos;} + Map view(LegacyUser u,long order){return db.readTransaction(()->{ + var detail=portal.detail(u,new CustomerPortal.Detail(order));var context=CustomerPortalScope.context(db,u);var allowed=centers(context);var job=job(order);long hq=id(job,"hq_id"),payer=id(job,"csc_id_payer"); + var cfg=new OrderCustomerMailContent(db,hq,"");long customer=0;var payerRows=db.queryForList("SELECT cs_id,csc_name FROM costcenter WHERE csc_id=?",payer);if(payerRows.size()>1)throw bad("Die bezahlende Kostenstelle ist mehrdeutig.");if(!payerRows.isEmpty())customer=id(payerRows.getFirst(),"cs_id"); + var summary=new ArrayList>();summary.add(field("Re.-Ko.-Nr.",text(job,"jb_commission_no")));summary.add(field("Besteller",text(job,"jb_orderer")));summary.add(field("Kostenstelle",allowed.contains(payer)&&!payerRows.isEmpty()?text(payerRows.getFirst(),"csc_name"):"")); + summary.add(field("Fahrzeugtyp",unique("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?","mt_value",id(job,"vht_id"))));summary.add(field("Auslage",OrderCustomerMailContent.money(BillingService.nz(decimal(job,"jb_outlay")))));summary.add(field("Gewicht",BillingService.nz(decimal(job,"jb_weight")).stripTrailingZeros().toPlainString()+" kg"));summary.add(field("Reservierung",id(job,"jb_reserv")==1?"Ja":"Nein"));summary.add(field("Annahme",OrderCustomerMailContent.time(job,"jb_taketime"))); + if(cfg.fallback("CUSTOMER_MASK_SHOW_USER_DATA").equals("1")&&id(job,"cr_id")>0)summary.add(field("Kurier",unique("SELECT CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS name FROM courier cr JOIN user u ON u.usr_id=cr.usr_id AND u.hq_id=cr.hq_id WHERE cr.cr_id=? AND cr.hq_id IN ("+CourierBourse.HQ_SQL+")","name",id(job,"cr_id"),mandator(db,u)))); + boolean pricePending=Boolean.TRUE.equals(detail.get("pricePending")); + var warnings=new ArrayList();if(pricePending)warnings.add(CustomerPendingPrice.MESSAGE); + var services=new ArrayList>();BigDecimal total=BigDecimal.ZERO;boolean gross=portal.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER");var serviceRows=db.queryForList("SELECT trs.csc_id,cc.csc_name,trs.trs_srv_name,trs.trs_price,trs.trs_discount FROM tourservice trs LEFT JOIN costcenter cc ON cc.csc_id=trs.csc_id WHERE trs.jb_id=? AND NOT (COALESCE(trs.trs_srvt_name,'')='z' AND COALESCE(trs.trs_price,0)=0) ORDER BY trs.csc_id,trs.tr_sort,trs.srv_id,trs.srvt_id LIMIT 1001",order);if(serviceRows.size()>1000)throw bad("Mehr als 1.000 Leistungen im Auftrag.");boolean partial=false; + for(var row:serviceRows){if(!allowed.contains(id(row,"csc_id"))){partial=true;continue;}BigDecimal amount=BillingService.nz(decimal(row,"trs_price")).multiply(BigDecimal.ONE.subtract(BillingService.nz(decimal(row,"trs_discount")).movePointLeft(2)));if(gross)amount=amount.multiply(new BigDecimal("1.19"));total=total.add(amount);services.add(fields("id",services.size()+1,"center",text(row,"csc_name"),"name",text(row,"trs_srv_name"),"price",BillingService.money(amount)));} + if(partial)warnings.add("Leistungen anderer Kostenstellen sind ausgeblendet. Die Leistungssumme umfasst nur Ihre freigegebenen Kostenstellen."); + BigDecimal toll=null;var tolls=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",order); + if(tolls.size()>1)warnings.add("Die Mautposition ist mehrfach gespeichert und wird deshalb nicht summiert."); + else if(!tolls.isEmpty()&&!text(tolls.getFirst(),"gdc_content").isBlank()){ + if(!allowed.contains(payer))warnings.add("Die Mautposition gehört zu einer nicht freigegebenen Rechnungskostenstelle und ist ausgeblendet."); + else {String value=text(tolls.getFirst(),"gdc_content").strip();if(value.matches("-?[0-9]{1,12}(\\.[0-9]{1,6})?"))toll=new BigDecimal(value);else warnings.add("Die Mautposition ist ungültig und wird deshalb nicht summiert.");} + } + // Unlike the mail renderer, PHP's customer detail adds the stored toll unchanged, even in related-customer gross mode. + var stops=db.queryForList("SELECT t.tr_id,t.tr_sort,t.tr_comp,t.csc_id,t.tr_sign,t.tr_ware_from_to FROM tour t WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 501",order);if(stops.size()>500)throw bad("Mehr als 500 Stationen im Kundenauftrag.");var stations=new ArrayList>();boolean arrival=cfg.object("JOB_MAIL_STATION_ARRIVAL_TIME",customer).equals("1"),scan=cfg.fallback("MASK_JOBDETAILS_STATIONS_POD_SCAN_EVENTS").equals("1"),counts=cfg.object("MASK_JOBDETAILS_STATIONS_POD_NUM_SCANNED_PACKAGES",customer).equals("1"),centerVisible=!cfg.object("JOB_MAIL_STATION_COSTCENTER_DISABLED",customer).equals("1");boolean tracking=customer>0&&unique("SELECT cs_tracking FROM customer WHERE cs_id=?","cs_tracking",customer).equals("1");String codes=cfg.object("JOBDETAILS_STATIONS_DELIVERY_CODES",customer); + for(var stop:stops){long station=id(stop,"tr_id");var notes=new ArrayList();var values=new ArrayList>();String signature=""; + if(centerVisible&&allowed.contains(id(stop,"csc_id")))values.add(field("Kostenstelle",unique("SELECT csc_name FROM costcenter WHERE csc_id=?","csc_name",id(stop,"csc_id")))); + if(arrival)try{values.add(field("Ankunftszeit",OrderDeliveryData.arrival(data.value("tr",station,"arr_tour"))));}catch(org.springframework.web.server.ResponseStatusException e){notes.add(e.getReason());} + if(tracking){var rows=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_obj_id=? AND gdc_gen_fieldname='tr_tracking'",station);if(rows.size()>1)throw bad("Die Tracking-Zuordnung ist mehrdeutig.");if(!rows.isEmpty()){values.add(field("E-Mail Endkunde",text(rows.getFirst(),"gdc_content").split("\\|",-1)[0]));String state=text(rows.getFirst(),"gdc_context");values.add(field("Trackingversand",state.equals("SENT_OK")?"Versandt":state.equals("SENT_NOK")?"Versand fehlgeschlagen":state.matches("[0-9]+")?"Vorgemerkt":""));}} + if(scan){values.add(field("Ablieferstatus",OrderDeliveryData.deliveryLabel(data.value("tr",station,"del_code"),id(stop,"tr_ware_from_to"),codes)));values.add(field("Statusgrund",OrderDeliveryData.publicRemark(data.value("tr",station,"del_rem"))));} + if(counts){var articles=db.queryForList("SELECT trat_id,trat_packingpieces FROM tourarticle WHERE jb_id=? AND tr_id=? ORDER BY trat_id LIMIT 2",order,station);if(articles.size()>1)notes.add("Die Paketanzahl ist mehreren Artikelpositionen zugeordnet und daher nicht eindeutig.");else if(articles.size()==1){var row=articles.getFirst();var countRows=db.queryForList("SELECT tratp_remark FROM tourarticleprocess WHERE trat_id=? AND tratp_remark LIKE 'cnt=%' LIMIT 2",id(row,"trat_id"));if(countRows.size()>1)notes.add("Mehrere Paketanzahlen gespeichert.");else{String count=countRows.isEmpty()?"0":text(countRows.getFirst(),"tratp_remark").substring(4);if(count.matches("[0-9]{1,9}"))values.add(field("Gescannte Pakete",count+" / "+text(row,"trat_packingpieces")));else notes.add("Die gespeicherte Paketanzahl ist ungültig.");}}} + try{var image=OrderDeliveryData.signature(text(stop,"tr_sign"));if(image!=null)signature=image.uri();}catch(org.springframework.web.server.ResponseStatusException e){notes.add(e.getReason());} + var photos=new ArrayList>();try{for(var p:pictures(order,station))photos.add(fields("key",p.key(),"name",p.name()));}catch(org.springframework.web.server.ResponseStatusException|org.springframework.dao.DataAccessException e){notes.add("Stationsfotos derzeit nicht verfügbar oder nicht eindeutig zugeordnet.");} + Map articles=fields("columns",List.of(),"rows",List.of());if(scan)try{articles=CustomerStationArticles.table(cfg,data,order,(int)id(stop,"tr_sort"));}catch(org.springframework.web.server.ResponseStatusException e){notes.add(e.getReason());} + stations.add(fields("label",(Boolean.TRUE.equals(detail.get("stationNumbers"))?"Station "+id(stop,"tr_sort")+" · ":"")+text(stop,"tr_comp"),"articles",articles,"id",station,"sequence",id(stop,"tr_sort"),"fields",visible(values),"signature",signature,"photos",photos,"warnings",notes)); + } + if(pricePending)services.forEach(s->s.put("price",null)); + var result=fields("stationNumbers",Boolean.TRUE.equals(detail.get("stationNumbers")),"pricePending",pricePending,"summary",visible(summary),"services",services,"serviceTotal",pricePending?null:BillingService.money(total),"toll",toll==null?null:BillingService.money(toll),"totalWithToll",pricePending||toll==null?null:BillingService.money(total.add(toll)),"serviceBasis",gross?"brutto (19 %)":"netto","partial",partial,"stations",stations,"warnings",warnings,"canPdf",id(job,"jb_status")==2); + result.put("revision",revision(List.of(detail,result)));return result; + });} + static Map field(String label,String value){return fields("label",label,"value",value);} + static List> visible(List> fields){return fields.stream().filter(f->!text(f,"value").isBlank()).toList();} + void stationAccess(LegacyUser u,long order,long station){portal.detail(u,new CustomerPortal.Detail(order));if(db.queryForList("SELECT tr_id FROM tour WHERE tr_id=? AND jb_id=?",station,order).size()!=1)throw CustomerPortalScope.denied();} + Map photo(LegacyUser u,long order,long station,String key){var image=db.readTransaction(()->{stationAccess(u,order,station);if(pictures(order,station).stream().noneMatch(p->p.key().equals(key)))throw CustomerPortalScope.denied();return data.image(station,key);});db.readTransaction(()->{stationAccess(u,order,station);if(pictures(order,station).stream().noneMatch(p->p.key().equals(key)))throw CustomerPortalScope.denied();return null;});return fields("name",image.name(),"mime",image.mime(),"data",image.uri());} + byte[] pdf(LegacyUser u,long order,String revision){return db.readTransaction(()->{var detail=portal.detail(u,new CustomerPortal.Detail(order));var evidence=view(u,order);if(!Boolean.TRUE.equals(evidence.get("canPdf")))throw bad("Der Auftragsbeleg ist erst für erledigte Aufträge verfügbar.");if(!Objects.equals(revision,text(evidence,"revision")))throw bad("Der Auftrag hat sich geändert. Bitte die Ansicht aktualisieren.");return OrderMailPdf.create(order,CustomerOrderDocument.html(detail,evidence));});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java new file mode 100644 index 0000000..b85caa0 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderHistory.java @@ -0,0 +1,32 @@ +package de.stadtbote.votian; + +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; + +/** Customer history exposes only PHP operations 1/10 and their employee contact projection. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{order}/history") +class CustomerOrderHistory { + static final int PAGE=50; + final DatabaseService db;final CustomerPortal portal;final String history; + CustomerOrderHistory(DatabaseService db,CustomerPortal portal,BusinessSupport support){this.db=db;this.portal=portal;this.history=support.historySchema;} + @GetMapping Object view(Authentication a,@PathVariable long order,@RequestParam(defaultValue="0") int page){return view(ApiController.user(a),order,page);} + Map view(LegacyUser user,long order,int page){return db.readTransaction(()->{ + portal.detail(user,new CustomerPortal.Detail(order));if(page<0||page>10000)throw bad("Ungültige Historienseite."); + String mode=portal.global(user.headquartersId(),"MASK_JOBDETAILS_USER_HISTORY");long mandator=mandator(db,user); + if(mode.isBlank()||mode.equals("0")){ + var creator=db.queryForList("SELECT u.usr_name,u.usr_firstname,u.usr_phone,u.usr_email FROM job j JOIN user u ON u.usr_id=j.emp_id WHERE j.jb_id=? AND u.hq_id IN ("+CourierBourse.HQ_SQL+")",order,mandator); + if(creator.size()>1)throw bad("Der Auftragserfasser ist mehrdeutig.");if(page!=0)throw bad("Die Erfasseransicht hat nur eine Seite."); + return fields("mode","creator","page",0,"hasNext",false,"rows",creator.stream().map(r->fields("id",order,"action","Erfasst","at",null,"name",text(r,"usr_name"),"firstName",text(r,"usr_firstname"),"phone",text(r,"usr_phone"),"email",text(r,"usr_email"))).toList()); + } + 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()); + }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/CustomerOrderItems.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderItems.java new file mode 100644 index 0000000..41a8e87 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderItems.java @@ -0,0 +1,42 @@ +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.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer detail articles and gross calculator lines from include/inc_job.inc.php. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{order}/items") +class CustomerOrderItems { + static final List KEYS=List.of("at_id","trat_quantity","trat_sort","trat_packingpieces","trat_serialno","trat_state","trat_remark","trat_price","trat_name","trat_remark","trat_weight","trat_id","tr_id","trat_int_serialno"); + static final List LABELS=List.of("ID","Menge","Sortierung","Packstücke","Seriennummer","Status","Bemerkung","Preis","Nummer","Artikelbeschreibung","Gewicht","Positions-ID","Stations-ID","Interne Seriennummer"); + final DatabaseService db;final CustomerPortal portal; + CustomerOrderItems(DatabaseService db,CustomerPortal portal){this.db=db;this.portal=portal;} + @GetMapping Object view(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);} + String parameter(long hq,String key){String value=portal.global(hq,key);return value.isEmpty()?portal.global(0,key):value;} + Map view(LegacyUser u,long order){return db.readTransaction(()->{ + portal.detail(u,new CustomerPortal.Detail(order)); + if(!portal.enabled(u.headquartersId(),"MASK_JOBDETAILS_JBP_BOOKING_ENABLED"))return fields("enabled",false); + var job=one(db.queryForList("SELECT jb_sales_tax_rate,jb_payment,csc_id_payer FROM job WHERE jb_id=?",order)); + if(id(job,"jb_payment")==1||id(job,"csc_id_payer")==0)return fields("enabled",true,"message","Artikel und Rechnungsposten eines Kostensplits benötigen eine eindeutige Kostenstellenzuordnung.","columns",List.of(),"articles",List.of(),"items",List.of()); + String raw=parameter(u.headquartersId(),"MASK_JOBDETAILS_ARTICLES");if(raw.isBlank())raw="9,8,1,10,3,7";String titles=parameter(u.headquartersId(),"MASK_JOBDETAILS_ARTICLE_HEADLINE");var names=titles.isBlank()?LABELS:Arrays.asList(titles.split(",",-1));var indexes=new ArrayList(); + for(String part:raw.split(",",-1)){if(!part.strip().matches("[0-9]{1,2}"))throw bad("Die Artikelspalten sind ungültig.");int index=Integer.parseInt(part.strip());if(index==99)continue;if(index<0||index>=KEYS.size())throw bad("Eine Artikelspalte benötigt einen aktualisierten Feldabgleich.");indexes.add(index);if(indexes.size()>30)throw bad("Zu viele Artikelspalten.");} + boolean scans=parameter(u.headquartersId(),"MASK_JOBDETAILS_ARTICLE_SCAN_EVENTS").equals("1");if(scans&&!indexes.contains(4))indexes.add(4); + var articles=db.queryForList("SELECT trat_id,tr_id,at_id,trat_quantity,trat_price,trat_sort,trat_packingpieces,trat_weight,trat_int_serialno,trat_serialno,trat_state,trat_remark,trat_name FROM tourarticle WHERE jb_id=? AND tr_sort=2 ORDER BY trat_sort,trat_id LIMIT 1001",order); + if(articles.size()>1000)throw bad("Mehr als 1.000 Artikel im Kundenauftrag."); + var rows=new ArrayList>();BigDecimal weight=BigDecimal.ZERO,price=BigDecimal.ZERO,pieces=BigDecimal.ZERO; + for(var article:articles){var cells=new ArrayList();for(int index:indexes){String value=text(article,KEYS.get(index));if(index==9&&value.length()>50)value=value.substring(0,50);cells.add(value);}rows.add(fields("id",id(article,"trat_id"),"cells",cells));weight=weight.add(BillingService.nz(decimal(article,"trat_weight")));price=price.add(BillingService.nz(decimal(article,"trat_price")));pieces=pieces.add(BillingService.nz(decimal(article,"trat_packingpieces")));} + var columns=indexes.stream().map(i->fields("label",i0){var values=db.queryForList("SELECT tx_value FROM tax WHERE tx_id=?",taxId);if(values.size()>1)throw bad("Der Umsatzsteuersatz ist mehrdeutig.");if(!values.isEmpty()&&values.getFirst().get("tx_value")!=null)tax=decimal(values.getFirst(),"tx_value");}if(tax.signum()<0||tax.compareTo(BigDecimal.valueOf(100))>0)throw bad("Ungültiger Umsatzsteuersatz.");BigDecimal factor=BigDecimal.ONE.add(tax.movePointLeft(2)),total=BigDecimal.ZERO; + var calc=db.queryForList("SELECT srv_name,srvt_name,jbc_amount,jbc_price,jbc_totalprice FROM jobcalculator WHERE jb_id=? AND tr_sort=2 ORDER BY srv_name,srv_id,srvt_id,tr_id LIMIT 1001",order);if(calc.size()>1000)throw bad("Mehr als 1.000 Rechnungsposten im Kundenauftrag.");var items=new ArrayList>(); + for(var item:calc){String name=text(item,"srv_name");if(name.length()>50)name=name.substring(0,50);BigDecimal value=BillingService.nz(decimal(item,"jbc_totalprice"));items.add(fields("name",name,"quantity",decimal(item,"jbc_amount"),"unitPrice",BillingService.money(BillingService.nz(decimal(item,"jbc_price")).multiply(factor)),"price",BillingService.money(value.multiply(factor))));total=total.add(value);} + var extra=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_addinfo'",order);if(extra.size()>1)throw bad("Die Artikel-Zusatzinformation ist mehrdeutig."); + return fields("enabled",true,"message","","columns",columns,"articles",rows,"items",items,"weight",weight,"pieces",pieces,"goodsValue",BillingService.money(price),"total",BillingService.money(total.multiply(factor)),"tax",tax,"additional",extra.isEmpty()?"":OrderDeliveryData.publicRemark(text(extra.getFirst(),"gdc_content")),"scanEvents",scans); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabelPdf.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabelPdf.java new file mode 100644 index 0000000..e4e43a0 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabelPdf.java @@ -0,0 +1,30 @@ +package de.stadtbote.votian; +import org.apache.pdfbox.pdmodel.*; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.oned.Code128Writer; +import java.io.*;import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; + +/** One vector Code-128 label per destination, with reversed addresses for returns. */ +final class CustomerOrderLabelPdf { + static Map page(byte[] bytes,int number){ + try(var pdf=org.apache.pdfbox.Loader.loadPDF(bytes)){if(number<0||number>=pdf.getNumberOfPages())throw bad("Dieses Etikett existiert nicht.");float width=pdf.getPage(number).getMediaBox().getWidth();float height=pdf.getPage(number).getMediaBox().getHeight();float dpi=Math.min(300,Math.min(900*72/width,1400*72/height));var image=new org.apache.pdfbox.rendering.PDFRenderer(pdf).renderImageWithDPI(number,dpi);var out=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",out);return OrderEntryService.fields("page",number,"pages",pdf.getNumberOfPages(),"png",Base64.getEncoder().encodeToString(out.toByteArray()));}catch(IOException e){throw new IllegalStateException("Etikettenvorschau konnte nicht erzeugt werden.",e);} + } + @SuppressWarnings("unchecked") static byte[] create(Map view){ + try(var pdf=new PDDocument();var fontFile=PDDocument.class.getResourceAsStream("/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf");var logoFile=CustomerOrderLabelPdf.class.getResourceAsStream("/labels/stadtbote-2019-bw.jpg")){ + if(fontFile==null||logoFile==null)throw new IOException("Etikettenressource fehlt.");var font=PDType0Font.load(pdf,fontFile,true);var logo=PDImageXObject.createFromByteArray(pdf,logoFile.readAllBytes(),"Stadtbote");String number=text(view,"job");var barcode=new Code128Writer().encode(number,BarcodeFormat.CODE_128,0,1,Map.of(EncodeHintType.MARGIN,20));float pageW=decimal(view,"width").floatValue()*72/25.4f,pageH=decimal(view,"height").floatValue()*72/25.4f; + for(var label:(List>)view.get("labels")){var page=new PDPage(new PDRectangle(pageW,pageH));pdf.addPage(page);float scale=Math.min(pageW/(60*72/25.4f),pageH/(40*72/25.4f));try(var stream=new PDPageContentStream(pdf,page)){stream.transform(org.apache.pdfbox.util.Matrix.getScaleInstance(scale,scale));float width=pageW/scale,height=pageH/scale,left=8.5f,right=width-4;stream.drawImage(logo,left,height-22,57,16.2f);line(stream,font,number,right-76,height-17,73,11); + float barcodeX=left,barcodeY=height-48,barcodeWidth=right-left,module=barcodeWidth/barcode.getWidth();stream.setNonStrokingColor(0f);for(int x=0;x)label.get("from"),"AB:",left,y,col);address(stream,font,(Map)label.get("to"),"AN:",left+col+8,y,col);line(stream,font,(text(view,"kind").equals("RETURN")?"Retoure · ":"")+"Station "+text(label,"station"),left,4.5f,right-left,4.5f); + }}pdf.getDocumentInformation().setTitle(text(view,"title")+" "+number);pdf.getDocumentInformation().setCreator("Votian NG");var out=new ByteArrayOutputStream();pdf.save(out);if(out.size()>12*1024*1024)throw bad("Die Etiketten überschreiten 12 MB.");return out.toByteArray(); + }catch(IllegalArgumentException e){throw bad("Ein Zeichen oder die Auftragsnummer kann im Etikett nicht dargestellt werden.");}catch(IOException e){throw new IllegalStateException("Etiketten konnten nicht erzeugt werden.",e);} + } + static void address(PDPageContentStream stream,PDType0Font font,Map address,String heading,float x,float y,float width)throws IOException{line(stream,font,heading,x,y,width,6);int n=1;for(String key:List.of("company","street","city","phone")){String value=text(address,key);if(!value.isBlank())line(stream,font,value,x,y-8*n++,width,5.8f);}} + static void line(PDPageContentStream stream,PDType0Font font,String raw,float x,float y,float width,float size)throws IOException{String value=raw.replaceAll("[\\p{Cntrl}]"," ");float measured=font.getStringWidth(value)/1000*size;if(measured>width)size=Math.max(3.4f,size*width/measured);if(font.getStringWidth(value)/1000*size>width)throw bad("Ein Adressfeld ist für dieses Etikett zu lang. Bitte die Adressdaten prüfen.");stream.beginText();stream.setFont(font,size);stream.newLineAtOffset(x,y);stream.showText(value);stream.endText();} + private CustomerOrderLabelPdf(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java new file mode 100644 index 0000000..0937e89 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderLabels.java @@ -0,0 +1,35 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Native equivalent of the currently configured 302/303 customer label templates. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{order}/labels") +class CustomerOrderLabels { + enum Kind {OUTBOUND,RETURN} + static final Map VERIFIED_TEMPLATES=Map.of(Kind.OUTBOUND,"f23fb057700fb79e61f6ab16599a0eb3055536ebe37563533246c32b024bef4b",Kind.RETURN,"dedc7901baee8256ef7b50db0e8857e4b6a7551b4da4e902b7fd079f3988b5be"); + static final Map VERIFIED_FIELDS=Map.of(1668L,"db:tr:comp",1664L,"db:tr:street",1665L,"db:tr:hsno",1666L,"db:tr:zipcode",1667L,"db:tr:city",1671L,"db:tr:phone",1712L,"db:tr:ware_from_to"); + final DatabaseService db;final CustomerPortal portal;final FormAdministration forms; + 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}/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;} + 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()); + } + Map view(LegacyUser u,long order,Kind kind){return db.readTransaction(()->{portal.detail(u,new CustomerPortal.Detail(order));return authorizedView(u,order,kind);});} + Map authorizedView(LegacyUser u,long order,Kind kind){var config=configuration(u,kind);var stops=db.queryForList("SELECT t.tr_id,t.tr_sort,t.tr_comp,t.tr_hsno,t.tr_phone,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,t.tr_id LIMIT 501",order);if(stops.size()<2)throw bad("Für Etiketten werden eine Startstation und mindestens eine Zielstation benötigt.");if(stops.size()>500)throw bad("Mehr als 500 Stationen im Auftrag.");var seq=new HashSet();for(var s:stops)if(id(s,"tr_sort")<1||!seq.add(id(s,"tr_sort")))throw bad("Die Stationsreihenfolge ist nicht eindeutig.");if(id(stops.getFirst(),"tr_sort")!=1)throw bad("Die Startstation 1 fehlt.");var labels=new ArrayList>();for(int i=1;i address(Map row,boolean phone){return fields("company",limit(text(row,"tr_comp"),30),"street",limit(text(row,"ad_street"),25)+" "+text(row,"tr_hsno"),"city",text(row,"ad_zipcode")+" "+limit(text(row,"ad_city"),25),"phone",phone?text(row,"tr_phone"):"");} + static String limit(String s,int n){return s.codePointCount(0,s.length())>n?s.substring(0,s.offsetByCodePoints(0,n)):s;} + byte[] pdf(LegacyUser u,long order,Kind kind,String revision){return db.readTransaction(()->{var view=view(u,order,kind);if(!Objects.equals(text(view,"revision"),revision))throw bad("Die Etikettendaten oder Vorlage haben sich geändert. Bitte die Vorschau neu laden.");return CustomerOrderLabelPdf.create(view);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java new file mode 100644 index 0000000..9c8f1a7 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderPhotos.java @@ -0,0 +1,37 @@ +package de.stadtbote.votian; + +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; + +/** Customer-owned mobile photos from job GDC and B2B photo blocks. No PHP temp files. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{order}/photos") +class CustomerOrderPhotos { + final DatabaseService db;final CustomerPortal portal;final OrderDeliveryData data;final DocumentStore store; + CustomerOrderPhotos(DatabaseService db,BusinessSupport support,CustomerPortal portal,PhotoFileStore store){this.db=db;this.portal=portal;this.data=new OrderDeliveryData(db,support.historySchema);this.store=store.store;} + @GetMapping Object view(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);} + @GetMapping("/{key}") Object image(Authentication a,@PathVariable long order,@PathVariable String key){return image(ApiController.user(a),order,key);} + void access(LegacyUser u,long order){portal.detail(u,new CustomerPortal.Detail(order));if(!portal.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))throw CustomerPortalScope.denied();} + boolean ambiguous(long order){return !db.queryForList("SELECT tr_id FROM tour WHERE tr_id=? AND jb_id<>? LIMIT 1",order,order).isEmpty();} + Map fileNames(long order){String raw=data.value("jb",order,"mobile_photos");var out=new LinkedHashMap();for(String name:raw.replace("-,-","|").split("\\|",-1)){name=name.strip();if(name.isEmpty())continue;if(!name.matches("[\\p{L}\\p{N}_][\\p{L}\\p{N}_. -]{0,199}\\.(?i:jpe?g|png)")||name.contains(".."))throw bad("Eine gespeicherte Fotoreferenz enthält einen ungültigen Dateinamen.");DocumentStore.component(name);out.put("file-"+DocumentStore.digest(name.getBytes(java.nio.charset.StandardCharsets.UTF_8)),name);if(out.size()>100)throw bad("Mehr als 100 Dateifotos im Auftrag.");}return out;} + Map view(LegacyUser u,long order){return db.readTransaction(()->{ + portal.detail(u,new CustomerPortal.Detail(order));if(!portal.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))return fields("enabled",false,"photos",List.of(),"warnings",List.of()); + var warnings=new ArrayList();var result=new ArrayList>();var names=fileNames(order);var found=new HashSet(); + if(!names.isEmpty()){if(!store.configured())warnings.add("Die Ablage für mobile Dateifotos ist noch nicht eingerichtet.");else try(var fs=store.open()){for(var name:names.values())if(fs.exists(name))found.add(name);}catch(org.springframework.web.server.ResponseStatusException e){warnings.add("Die Ablage für mobile Dateifotos ist derzeit nicht erreichbar.");}} + for(var entry:names.entrySet())result.add(fields("key",entry.getKey(),"name",entry.getValue(),"source","Mobile Dateifotos","canOpen",found.contains(entry.getValue()),"status",found.contains(entry.getValue())?"Verfügbar":"Datei derzeit nicht verfügbar")); + // Legacy B2B IDs have no object-type namespace. Never use a colliding foreign station ID. + if(ambiguous(order))warnings.add("B2B-Fotos sind wegen einer mehrdeutigen Auftrags-/Stationsnummer gesperrt.");else for(var photo:data.b2bPictures(order))result.add(fields("key",photo.key(),"name",photo.name(),"source","B2B","canOpen",true,"status","Verfügbar")); + return fields("enabled",true,"photos",result,"warnings",warnings); + });} + Map image(LegacyUser u,long order,String key){ + if(key==null||!key.matches("(?:file-[0-9a-f]{64}|b2b-[1-9][0-9]{0,18})"))throw bad("Ungültige Fotokennung."); + if(key.startsWith("b2b-")){var image=db.readTransaction(()->{access(u,order);if(ambiguous(order))throw bad("Die B2B-Fotozuordnung ist mehrdeutig.");return data.jobImage(order,key);});db.readTransaction(()->{access(u,order);if(ambiguous(order)||data.b2bPictures(order).stream().noneMatch(p->p.key().equals(key)))throw CustomerPortalScope.denied();return null;});return fields("name",image.name(),"mime",image.mime(),"data",image.uri());} + String name=db.readTransaction(()->{access(u,order);var names=fileNames(order);if(!names.containsKey(key))throw CustomerPortalScope.denied();return names.get(key);});byte[] bytes;try(var fs=store.open()){bytes=fs.read(name,OrderDeliveryData.MAX_IMAGE);}String mime=OrderDeliveryData.validateImage(bytes); + db.readTransaction(()->{access(u,order);if(!name.equals(fileNames(order).get(key)))throw CustomerPortalScope.denied();return null;});var image=new OrderDeliveryData.Image(name,mime,bytes);return fields("name",name,"mime",mime,"data",image.uri()); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java b/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java new file mode 100644 index 0000000..096058d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderRoute.java @@ -0,0 +1,43 @@ +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.*;import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer detail's showInGoogleMaps link: current courier position or last completed stop. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{order}/route") +class CustomerOrderRoute { + final DatabaseService db;final CustomerPortal portal; + CustomerOrderRoute(DatabaseService db,CustomerPortal portal){this.db=db;this.portal=portal;} + @GetMapping Object view(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);} + Map view(LegacyUser u,long order){return db.readTransaction(()->{ + var detail=portal.detail(u,new CustomerPortal.Detail(order));String mode=portal.global(u.headquartersId(),"MASK_JOBDETAILS_USER_HISTORY"); + if(id(detail,"status")!=1||id(detail,"cancelled")!=0||mode.isBlank()||mode.equals("0"))return fields("enabled",false); + var job=one(db.queryForList("SELECT cr_id,cr_sid FROM job WHERE jb_id=?",order)); + var stops=db.queryForList("SELECT t.tr_id,t.tr_sort,t.tr_status,t.tr_hsno,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 501",order); + if(stops.size()>500)throw bad("Mehr als 500 Stationen im Kundenauftrag.");var positions=new HashSet();for(var s:stops)if(id(s,"tr_sort")<1||!positions.add(id(s,"tr_sort")))throw bad("Die Stationsreihenfolge ist nicht eindeutig."); + Map previous=null,target=null;for(var stop:stops){if(id(stop,"tr_status")!=1){target=stop;break;}previous=stop;} + if(target==null)return unavailable("Keine unerledigte Station mehr vorhanden."); + String destination=address(target);if(destination.isEmpty())return unavailable("Die nächste Station hat keine vollständige Kartenadresse."); + String origin="",source="",locatedAt=""; + if(id(job,"cr_id")>0){ + var couriers=db.queryForList("SELECT cr_gps_time,cr_gps_type,cr_gps_long,cr_gps_lat FROM courier WHERE cr_id=? AND cr_sid=? AND hq_id IN ("+CourierBourse.HQ_SQL+")",id(job,"cr_id"),text(job,"cr_sid"),mandator(db,u)); + if(couriers.size()>1)throw bad("Der zugeordnete Transporteur ist mehrdeutig."); + if(couriers.size()==1){var cr=couriers.getFirst();var at=dateTime(cr,"cr_gps_time");var now=dateTime(one(db.queryForList("SELECT NOW() AS at")),"at");long limit=FleetLocation.integer(portal.global(0,"LOCATING_SECONDS_NEEDED_FOR_LBS_LOCATING"),900,1,86400,"Maximales Ortungsalter");var point=FleetMap.position(cr,"cr_gps_lat","cr_gps_long"); + if(point!=null&&at!=null&&Set.of(0L,2L,3L).contains(id(cr,"cr_gps_type"))&&!at.isAfter(now)&&Duration.between(at,now).getSeconds()<=limit){origin=point.latitude()+","+point.longitude();source="Aktuelle Fahrzeugposition";locatedAt=at.toString();} + } + } + if(origin.isBlank()&&previous!=null){origin=address(previous);source="Letzte erledigte Station "+id(previous,"tr_sort");} + if(origin.isBlank())return unavailable("Keine aktuelle Fahrzeugposition verfügbar und noch keine erledigte Station mit vollständiger Kartenadresse."); + 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 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 new file mode 100644 index 0000000..77ffb0f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerOrderTexts.java @@ -0,0 +1,83 @@ +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 java.util.regex.Pattern; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer-visible text corrections; identity, routing, prices and evidence remain bound to the order. */ +@RestController +@RequestMapping("/api/customer-portal/orders/{job}/texts") +class CustomerOrderTexts { + record Stop(long id,String company,String person,String phone,String reference,String remark){} + record Command(String requestId,String revision,String reference,String notes,List stops,boolean confirmed){} + record State(long customer,String rights,Map job,List> stops,List> metadata,List> invoices,List> corrections,boolean enabled,boolean remarks,boolean referenceRequired){} + record Change(String field,String before,String after){} + static final Pattern PROTECTED=Pattern.compile("\\[\\[.*?(?:\\]\\]|$)|\\*\\*\\* ZAHLT BAR \\*\\*\\*|\\*\\*\\* Bitte im Auftrag STADTBOTE Bar [^\\r\\n]* kassieren und quittieren\\. \\*\\*\\*",Pattern.DOTALL); + final DatabaseService db;final BusinessSupport support; + CustomerOrderTexts(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping Object options(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);} + @PostMapping("/preview") Object preview(Authentication a,@PathVariable long job,@RequestBody Command c){return preview(ApiController.user(a),job,c);} + @PostMapping Object save(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);} + State state(LegacyUser u,long job){ + var context=CustomerPortalScope.context(db,u);context.require(4);long customer=id(context.account(),"cs_id");var own=CustomerPortalScope.customer(context,customer); + var rows=db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",job,u.headquartersId()); + if(rows.size()!=1||!own.allowed().contains(CustomerEntryTours.payer(rows.getFirst()))||!own.allowed().contains(id(rows.getFirst(),"csc_id")))throw CustomerPortalScope.denied(); + var catalog=new OrderEntryCatalog(db);var settings=one(db.queryForList("SELECT cs_jbedit,cs_jb_commission_no FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId())); + var headquarters=one(db.queryForList("SELECT hq_workmode FROM headquarters WHERE hq_id=?",u.headquartersId())); + boolean enabled=catalog.enabled(u.headquartersId(),"MASK_JB_CS_EDITJOB")&&(id(headquarters,"hq_workmode")==0||id(settings,"cs_jbedit")!=0); + return new State(customer,context.rights(),rows.getFirst(),db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job),db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_invoice_lock','jb_invtext_dirty') ORDER BY gdc_id",job),db.queryForList("SELECT * FROM invoice WHERE jb_id=?",job),db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4) ORDER BY jb_id",job),enabled,catalog.enabled(u.headquartersId(),"MASK_CUSTOMER_SHOW_REMARK"),id(settings,"cs_jb_commission_no")==1&&catalog.enabled(u.headquartersId(),"MASK_COMMISSION_NO_MANDATORY")); + } + String blocked(State s){ + if(!s.enabled())return "Die Kundenbearbeitung ist nicht freigegeben."; + var j=s.job();if(!Set.of(8L,9L).contains(id(j,"jb_status"))||id(j,"jb_storno")!=0)return "Nur noch nicht angenommene Kundenaufträge mit Status 8 oder 9 können bearbeitet werden."; + if(OrderOperations.exported(j)||!s.invoices().isEmpty()||s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))return "Der Auftrag ist bereits exportiert oder einer Rechnung zugeordnet."; + if(!s.corrections().isEmpty())return "Für diesen Auftrag besteht bereits eine Gegenbuchung."; + if(id(j,"jb_id_parent")>0)return "Unteraufträge werden über den Hauptauftrag bearbeitet."; + var lock=dateTime(j,"jb_locktime");if(lock!=null&&lock.isAfter(LocalDateTime.now().minusMinutes(5)))return "Der Auftrag wird gerade in der alten Anwendung bearbeitet."; + if(s.stops().isEmpty()||s.stops().size()>100||s.stops().stream().anyMatch(t->id(t,"tr_status")!=0||dateTime(t,"tr_finishtime")!=null||!text(t,"tr_signname").isBlank()))return "Stationen mit Zustellnachweisen können hier nicht geändert werden."; + return ""; + } + Map options(LegacyUser u,long job){return db.readTransaction(()->{var s=state(u,job);String reason=blocked(s);return fields("id",job,"revision",revision(s),"canEdit",reason.isEmpty(),"reason",reason,"remarks",s.remarks(),"referenceRequired",s.referenceRequired(),"reference",text(s.job(),"jb_commission_no"),"notes",visible(text(s.job(),"jb_freetext_1")),"stops",s.stops().stream().map(t->fields("id",id(t,"tr_id"),"sequence",id(t,"tr_sort"),"address",text(t,"ad_street")+" "+text(t,"tr_hsno")+", "+text(t,"ad_zipcode")+" "+text(t,"ad_city"),"company",text(t,"tr_comp"),"person",text(t,"tr_person"),"phone",text(t,"tr_phone"),"reference",text(t,"tr_commission_no"),"remark",s.remarks()?visible(text(t,"tr_remark")):"")).toList());});} + static String visible(String raw){return PROTECTED.matcher(raw).replaceAll("").strip();} + static String input(String value,int max,boolean required,String label){String v=Objects.toString(value,"").strip();if(v.length()>max||required&&v.isEmpty()||v.contains("[[")||v.contains("]]")||v.contains("*** ZAHLT BAR ***")||v.contains("*** Bitte im Auftrag STADTBOTE Bar ")||v.codePoints().anyMatch(n->Character.isISOControl(n)&&n!='\n'&&n!='\t'))throw bad("Bitte das Feld „"+label+"“ prüfen (maximal "+max+" Zeichen). Interne Kennzeichnungen sind nicht zulässig.");return v;} + static String replace(String raw,String value,int max,String label){ + if(visible(raw).equals(value))return raw; + var protectedParts=new ArrayList();var matcher=PROTECTED.matcher(raw);while(matcher.find())protectedParts.add(matcher.group()); + String result=value+(protectedParts.isEmpty()?"":(value.isEmpty()?"":"\n")+String.join("\n",protectedParts)); + if(result.length()>max)throw bad("Das Feld „"+label+"“ ist zusammen mit den geschützten Vermerken zu lang.");return result; + } + record Prepared(Map job,List> stops,List changes){} + Prepared prepare(State s,Command c){ + if(c==null)throw bad("Änderungsdaten fehlen.");BusinessSupport.token(c.requestId());unchanged(s,c.revision());String reason=blocked(s);if(!reason.isEmpty())throw bad(reason); + if(c.stops()==null||c.stops().size()!=s.stops().size())throw bad("Bitte alle bestehenden Stationen unverändert zuordnen."); + var changes=new ArrayList();String ref=input(c.reference(),50,s.referenceRequired(),"Kommissionsnummer"),notes=input(c.notes(),10000,false,"Auftragstext"); + change(changes,"Kommissionsnummer",text(s.job(),"jb_commission_no"),ref);change(changes,"Auftragstext",visible(text(s.job(),"jb_freetext_1")),notes); + var job=fields("jb_commission_no",ref,"jb_freetext_1",replace(text(s.job(),"jb_freetext_1"),notes,10000,"Auftragstext"));var stops=new ArrayList>(); + for(int n=0;n changes,String field,String before,String after){if(!before.equals(after))changes.add(new Change(field,before,after));} + Map preview(LegacyUser u,long job,Command c){return db.readTransaction(()->{var s=state(u,job);var p=prepare(s,c);return fields("id",job,"changes",p.changes(),"revision",revision(s));});} + Map save(LegacyUser u,long job,Command c){ + if(c==null||!c.confirmed())throw bad("Bitte die Änderungen bestätigen.");BusinessSupport.token(c.requestId());support.transactional("job","tour","genericdatacontainer"); + return db.customerOrderTextsWriteTransaction(()->{ + var s=state(u,job);if(!s.enabled())throw CustomerPortalScope.denied();var intent=List.of("CUSTOMER_ORDER_TEXTS",job,c);Long prior=support.receipt(u,c.requestId(),intent);if(prior!=null)return fields("id",prior,"replayed",true); + 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); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPendingPrice.java b/backend/src/main/java/de/stadtbote/votian/CustomerPendingPrice.java new file mode 100644 index 0000000..6eedc7e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPendingPrice.java @@ -0,0 +1,35 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.util.List; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** check_jb_incomplete: a customer may submit an order whose fare needs central review. */ +final class CustomerPendingPrice { + static final String MESSAGE="Der Fahrpreis ist noch nicht vollständig ermittelt. Die Zentrale prüft den Preis; der Auftrag ist zur Nachbearbeitung vorgemerkt."; + static final String CASH="*** Fahrpreis noch ungeklärt. Kassierbetrag vor der Zahlung mit der Zentrale abstimmen. ***"; + static final String KEY="ng_customer_price_pending"; + static final String SQL="(jb.jb_incomplete=1 AND EXISTS (SELECT 1 FROM genericdatacontainer pendingPrice WHERE pendingPrice.gdc_obj_type='jb' AND pendingPrice.gdc_obj_id=jb.jb_id AND pendingPrice.gdc_gen_fieldname='"+KEY+"' AND pendingPrice.gdc_content='1'))"; + static boolean missing(List components){ + BigDecimal sum=BigDecimal.ZERO; + for(var c:components){ + if(c.type().equals("z")||c.name().contains("Grundpreis"))continue; + if(c.price().abs().compareTo(new BigDecimal("0.02"))<0)return true; + sum=sum.add(c.price().multiply(BigDecimal.ONE.subtract(c.discount().movePointLeft(2)))); + } + return sum.signum()==0; + } + static Component zeroBasic(Component c){ + return c.name().equals("Grundpreis")?new Component(c.sequence(),c.name(),c.type(),BigDecimal.ZERO,c.discount(),BigDecimal.ZERO,BigDecimal.ZERO,c.courierNet(),c.courierSubtotal()):c; + } + static void clear(DatabaseService db,long job){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,KEY);} + static void save(DatabaseService db,long job,boolean pending){ + clear(db,job); + if(pending)db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname",KEY,"gdc_content","1","gdc_context","CUSTOMER_ENTRY")); + } + static String cashRemark(Quote quote,BigDecimal toll){ + return "\n*** ZAHLT BAR ***\n"+(quote.pricePending()?CASH:"*** Bitte im Auftrag STADTBOTE Bar "+LegacyOrderPricing.money(quote.net().add(LegacyOrderPricing.nz(toll)))+" EUR zzgl. "+quote.tax()+" EUR MwSt (Gesamt "+quote.gross()+" EUR) kassieren und quittieren. ***"); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java new file mode 100644 index 0000000..9cc0bae --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPhotoSettings.java @@ -0,0 +1,133 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.JsonNode; +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; + +/** PHP customer_special.php /sets/kunde and /settings/kunde, with durable delivery evidence. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/photo-settings") +class CustomerPhotoSettings { + static final String URL="URL_CS_PHOTO_WORKFLOW",OBJECT="ngcsph"; + record Flow(int number,String name,int minimum,int maximum){} + record SetDefinition(int number,String name,List workflows){} + record Selection(int setNumber,List allowedWorkflowNumbers,int defaultWorkflowNumber){} + record Values(int defaultSetNumber,List sets){} + record Context(long customer,long headquarters,String label,String url,String revision){} + record State(Context context,List definitions,Values values,String revision){} + record Draft(String revision,Values values){} + record Command(String requestId,Draft draft,String fingerprint){} + record Preview(String fingerprint,Values before,Values after){} + record Plan(long user,long originalHeadquarters,Context context,Values before,Values after,String intent){} + record Operation(String requestId,String status,boolean canCheck,String message){} + record View(long customer,String label,boolean configured,String revision,List definitions,Values values,List operations){} + record Copy(String revision,Context context,Values before,Values after){} + 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("/{token}/check") Operation check(Authentication a,@PathVariable long customer,@PathVariable String token){return deliver(ApiController.user(a),customer,token);} + + 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."); + var owner=customer==0?fields("cs_id",0):one(db.queryForList("SELECT cs.cs_id,cs.cmp_id,cs.hq_id,cs.cs_eid,c.cmp_comp FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=?",customer,u.headquartersId())); + var rows=db.queryForList("SELECT par_id,hq_id,par_value FROM parameter WHERE par_key=? AND emp_id=0 AND hq_id IN (0,?) ORDER BY hq_id,par_id",URL,u.headquartersId());var endpoints=new HashMap(); + for(var row:rows)if(endpoints.put(id(row,"hq_id"),text(row,"par_value").strip())!=null)throw bad("Mehrdeutige Konfiguration des Fotodiensts."); + String url=endpoints.getOrDefault(u.headquartersId(),"");if(url.isBlank())url=endpoints.getOrDefault(0L,"");url=url.replaceAll("/+$",""); + if(!url.isEmpty()){try{var uri=java.net.URI.create(url);if(!Set.of("http","https").contains(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||uri.getQuery()!=null||uri.getFragment()!=null)throw new IllegalArgumentException();}catch(Exception e){throw bad("Der Fotodienst ist nicht korrekt konfiguriert.");}} + return new Context(customer,u.headquartersId(),customer==0?"Neue Kunden":text(owner,"cs_eid")+" · "+text(owner,"cmp_comp"),url,revision(List.of(fields("cs_id",customer,"cmp_id",id(owner,"cmp_id"),"hq_id",u.headquartersId()),rows))); + } + static boolean sameContext(Context a,Context b){return a.customer()==b.customer()&&a.headquarters()==b.headquarters()&&a.url().equals(b.url())&&a.revision().equals(b.revision());} + static int number(JsonNode node,String name,boolean zero){var value=node.path(name);if(!value.asText().matches(zero?"[0-9]{1,9}":"[1-9][0-9]{0,8}"))throw bad("Der Fotodienst liefert ungültige Nummern oder Mengen.");return value.asInt();} + static boolean flag(JsonNode node,String name,boolean fallback){if(!node.hasNonNull(name))return fallback;if(!node.path(name).isBoolean())throw bad("Der Fotodienst liefert ungültige Freigaben.");return node.path(name).asBoolean();} + static String label(JsonNode node){String name=node.path("name").asText();if(name.isBlank()||name.length()>500)throw bad("Der Fotodienst liefert eine ungültige Bezeichnung.");return name;} + static State parse(Context context,JsonNode response){ + var items=OrderPhotoWorkflows.result(response);if(items.size()>100)throw bad("Zu viele Foto-Sets."); + var definitions=new ArrayList();var selections=new ArrayList();var ids=new HashSet();int defaultSet=0; + for(var item:items){int set=number(item,"number",false);if(!ids.add(set)||!item.path("workflows").isArray()||item.path("workflows").size()>100)throw bad("Ungültige oder doppelte Foto-Sets."); + var flows=new ArrayList();var allowed=new ArrayList();var flowIds=new HashSet();int defaultFlow=0;boolean hasDefault=false; + for(var flow:item.path("workflows")){int n=number(flow,"number",true);if(!flowIds.add(n))throw bad("Doppelte Foto-Workflows.");int min=flow.hasNonNull("min")?number(flow,"min",true):0,max=flow.hasNonNull("max")?number(flow,"max",true):0;if(max definitions){return normalize(values,definitions,true);} + static Values normalize(Values values,List definitions,boolean writing){ + if(values==null||values.sets()==null||values.sets().size()!=definitions.size())throw bad("Die Auswahl muss alle Foto-Sets enthalten."); + var selected=new TreeMap();for(var s:values.sets()){if(s==null||selected.put(s.setNumber(),s)!=null)throw bad("Doppelte Foto-Set-Auswahl.");} + if(values.defaultSetNumber()!=0&&definitions.stream().noneMatch(d->d.number()==values.defaultSetNumber()))throw bad("Unbekanntes Standard-Foto-Set."); + var result=new ArrayList();for(var d:definitions){var s=selected.get(d.number());if(s==null||s.allowedWorkflowNumbers()==null)throw bad("Foto-Set-Auswahl fehlt.");var allowed=new TreeSet();for(var n:s.allowedWorkflowNumbers()){if(n==null||!allowed.add(n)||d.workflows().stream().noneMatch(f->f.number()==n))throw bad("Unbekannter oder doppelt gewählter Foto-Workflow.");} + // PHP sends zero both for no default and for workflow number zero. + if(s.defaultWorkflowNumber()!=0&&(writing?!allowed.contains(s.defaultWorkflowNumber()):d.workflows().stream().noneMatch(f->f.number()==s.defaultWorkflowNumber())))throw bad("Der Standard-Workflow muss freigegeben sein."); + result.add(new Selection(d.number(),List.copyOf(allowed),s.defaultWorkflowNumber())); + }return new Values(values.defaultSetNumber(),List.copyOf(result)); + } + State state(LegacyUser u,long customer){ + var c=db.readTransaction(()->context(u,customer));if(c.url().isBlank())return new State(c,List.of(),new Values(0,List.of()),revision(List.of(c.revision()))); + var state=parse(c,client.get(c.url()+"/sets/kunde/"+customer,"Der Fotodienst")); + if(!sameContext(db.readTransaction(()->context(u,customer)),c))throw conflict();return state; + } + Copy copy(LegacyUser u,long source){ + var original=state(u,source);var defaults=state(u,0); + if(!original.context().url().equals(defaults.context().url()))throw conflict(); + var after=normalize(original.values(),original.definitions()); + return new Copy(revision(List.of(original.revision(),defaults.revision())),original.context(),defaults.values(),after); + } + void enqueueCopy(LegacyUser u,long customer,String token,Copy copy){ + if(!sameContext(context(u,copy.context().customer()),copy.context()))throw conflict(); + if(copy.context().url().isBlank())return; + var target=context(u,customer);if(!target.url().equals(copy.context().url()))throw conflict(); + enqueue(u,token,new Plan(u.userId(),u.originalHeadquartersId(),target,copy.before(),copy.after(),copy.revision())); + } + View view(LegacyUser u,long customer){if(customer<=0)throw bad("Bitte zuerst den Kunden anlegen.");var s=state(u,customer);return new View(customer,s.context().label(),!s.context().url().isBlank(),s.revision(),s.definitions(),s.values(),operations(u,customer));} + Preview prepare(LegacyUser u,long customer,Draft d,State s){if(d==null||!s.revision().equals(d.revision()))throw conflict();if(s.context().url().isBlank())throw bad("Der Fotodienst ist nicht eingerichtet.");var after=normalize(d.values(),s.definitions());return new Preview(revision(List.of(u.userId(),u.originalHeadquartersId(),customer,d.revision(),after)),s.values(),after);} + Preview preview(LegacyUser u,long customer,Draft d){if(customer<=0)throw bad("Bitte zuerst den Kunden anlegen.");return prepare(u,customer,d,state(u,customer));} + Plan plan(Map row){try{return support.json.readValue(text(row,"gdc_content"),Plan.class);}catch(Exception e){throw bad("Der gespeicherte Foto-Vorgang ist nicht lesbar.");}} + Map find(String token,boolean lock){String sql="SELECT gdc_id,gdc_obj_id,gdc_gen_fieldname,gdc_context,gdc_content FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,OBJECT,token):db.queryForList(sql,OBJECT,token);if(rows.size()>1)throw bad("Mehrdeutiger Foto-Vorgang.");return rows.isEmpty()?Map.of():rows.getFirst();} + void owner(LegacyUser u,long customer,Plan p){if(p.user()!=u.userId()||p.originalHeadquarters()!=u.originalHeadquartersId()||p.context().headquarters()!=u.headquartersId()||p.context().customer()!=customer)throw new org.springframework.security.access.AccessDeniedException("Dieser Foto-Vorgang gehört zu einem anderen Benutzer oder Kunden.");} + List operations(LegacyUser u,long customer){return db.readTransaction(()->{context(u,customer);var rows=db.queryForList("SELECT gdc_gen_fieldname,gdc_context,gdc_content FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_context NOT IN ('DONE','CONFLICT') ORDER BY gdc_id",OBJECT,customer);return rows.stream().map(r->operation(u,r)).toList();});} + Operation operation(LegacyUser u,Map row){var p=plan(row);String status=text(row,"gdc_context");String message=switch(status){case "DONE"->"Fotoeinstellungen wurden beim Fotodienst bestätigt.";case "CONFLICT"->"Die Fotoeinstellungen wurden anderweitig verändert. Bitte neu laden und erneut prüfen.";case "PENDING"->"Die Fotoeinstellungen warten auf Übertragung.";default->"Die Übertragung ist noch nicht bestätigt. Status prüfen liest den Fotodienst erneut und sendet die Änderung nicht erneut.";};return new Operation(text(row,"gdc_gen_fieldname"),status,p.user()==u.userId()&&p.originalHeadquarters()==u.originalHeadquartersId(),message);} + void enqueue(LegacyUser u,String token,Plan p){ + if(!db.lockRows("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_context NOT IN ('DONE','CONFLICT')",OBJECT,p.context().customer()).isEmpty())throw bad("Für diesen Kunden ist noch eine Foto-Übertragung ungeklärt. Bitte zuerst deren Status prüfen."); + db.insert("genericdatacontainer",fields("gdc_obj_type",OBJECT,"gdc_obj_id",p.context().customer(),"gdc_gen_fieldname",token,"gdc_content",support.encode(p),"gdc_context","PENDING")); + } + Operation save(LegacyUser u,long customer,Command c){ + if(c==null)throw bad("Fotoänderung fehlt.");BusinessSupport.token(c.requestId());if(customer<=0)throw bad("Bitte zuerst den Kunden anlegen."); + db.readTransaction(()->context(u,customer));String intent=revision(c);var old=db.readTransaction(()->find(c.requestId(),false)); + if(!old.isEmpty()){var p=plan(old);owner(u,customer,p);if(!p.intent().equals(intent))throw conflict();return deliver(u,customer,c.requestId());} + var s=state(u,customer);var preview=prepare(u,customer,c.draft(),s);if(!preview.fingerprint().equals(c.fingerprint()))throw conflict();support.transactional("headquarters","customer","company","genericdatacontainer"); + db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId());if(!sameContext(context(u,customer),s.context()))throw conflict();var existing=find(c.requestId(),true);if(!existing.isEmpty()){var p=plan(existing);owner(u,customer,p);if(!p.intent().equals(intent))throw conflict();return null;}enqueue(u,c.requestId(),new Plan(u.userId(),u.originalHeadquartersId(),s.context(),preview.before(),preview.after(),intent));return null;});return deliver(u,customer,c.requestId()); + } + Operation deliver(LegacyUser u,long customer,String token){ + BusinessSupport.token(token);support.transactional("headquarters","genericdatacontainer"); + record Claim(Map row,boolean send){} + var claim=db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId());var current=context(u,customer);var row=find(token,true);if(row.isEmpty())throw bad("Foto-Vorgang nicht gefunden.");var p=plan(row);owner(u,customer,p);String status=text(row,"gdc_context");if(Set.of("DONE","CONFLICT").contains(status))return new Claim(row,false);if(!sameContext(current,p.context()))throw conflict();boolean send=status.equals("PENDING");if(send){db.update("UPDATE genericdatacontainer SET gdc_context='SENDING' WHERE gdc_id=?",id(row,"gdc_id"));row=new LinkedHashMap<>(row);row.put("gdc_context","SENDING");}return new Claim(row,send);}); + var p=plan(claim.row());if(Set.of("DONE","CONFLICT").contains(text(claim.row(),"gdc_context")))return operation(u,claim.row()); + boolean attempted=false; + try{ + var current=state(u,customer);if(!sameContext(current.context(),p.context()))throw conflict();if(current.values().equals(p.after()))return finish(u,customer,token,"DONE"); + if(!claim.send())return operation(u,claim.row()); + if(!current.values().equals(p.before()))return finish(u,customer,token,"CONFLICT"); + // This is the only sending branch. A repeated or uncertain call only reads. + if(!sameContext(db.readTransaction(()->context(u,customer)),p.context()))throw conflict(); + attempted=true;client.post(p.context().url()+"/settings/kunde/"+customer,p.after(),"Der Fotodienst"); + var confirmed=state(u,customer);if(sameContext(confirmed.context(),p.context())&&confirmed.values().equals(p.after()))return finish(u,customer,token,"DONE"); + }catch(org.springframework.security.access.AccessDeniedException e){ + if(claim.send()&&!attempted)resetPending(claim.row());throw e; + }catch(org.springframework.web.server.ResponseStatusException e){if(claim.send()&&!attempted){resetPending(claim.row());var row=new LinkedHashMap<>(claim.row());row.put("gdc_context","PENDING");return operation(u,row);}return operation(u,claim.row());} + return operation(u,claim.row()); + } + void resetPending(Map row){db.writeTransaction(()->db.update("UPDATE genericdatacontainer SET gdc_context='PENDING' WHERE gdc_id=? AND gdc_context='SENDING'",id(row,"gdc_id")));} + Operation finish(LegacyUser u,long customer,String token,String status){return db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId());context(u,customer);var row=find(token,true);owner(u,customer,plan(row));if(!Set.of("DONE","CONFLICT").contains(text(row,"gdc_context"))){support.audit(u,0,customer,0,203,status.equals("DONE")?"Foto-Workflow-Einstellungen beim Fotodienst bestätigt":"Foto-Workflow-Einstellungen wegen Bestandskonflikt nicht übertragen",token);db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?",status,id(row,"gdc_id"));row=new LinkedHashMap<>(row);row.put("gdc_context",status);}return operation(u,row);});} + static org.springframework.web.server.ResponseStatusException conflict(){return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Fotoeinstellungen oder ihre Konfiguration wurden inzwischen geändert. Bitte neu laden und erneut prüfen.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java b/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java new file mode 100644 index 0000000..5e15fd1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPortal.java @@ -0,0 +1,107 @@ +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.*;import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer list and explicit public order projection, based on customer/jb_list.php. */ +@RestController +@RequestMapping("/api/customer-portal") +class CustomerPortal { + record Query(LocalDate from,LocalDate to,int status,long customer,long center,boolean descendants,String search,boolean stations,int page,String sort,boolean descending){} + record Detail(long job){} + static final int PAGE=50; + static final List DEFAULT=List.of("jb_id","business_volume_with_toll","tr_commission_no","tr2_commission_no","csc_name","tr_comp","tr_comp2","tr2_comp","streethsno","ad_zipcode","target_address","vht_value","jb_ordertime","cr_sid"); + static final List STATIONS=List.of("jb_id","tr_commission_no","csc_name","tr_comp","tr_comp2","streethsno","ad_zipcode","ad_city","vht_value","jb_ordertime","cr_sid","tr_remark"); + static final Map LABELS=Map.ofEntries(Map.entry("jb_id","Nr."),Map.entry("business_volume_with_toll","Preis"),Map.entry("business_volume","Nettopreis ohne Maut"),Map.entry("gdc_toll_content","Maut"),Map.entry("business_volume_gross_with_toll","Bruttopreis inkl. Maut"),Map.entry("tr_commission_no","Kommissionsnr."),Map.entry("tr2_commission_no","Referenznr."),Map.entry("csc_name","Kostenstelle"),Map.entry("tr_comp","Firma"),Map.entry("tr_comp2","Zusatz"),Map.entry("tr2_comp","Kunde"),Map.entry("streethsno","Startadresse"),Map.entry("ad_zipcode","PLZ"),Map.entry("ad_city","Ort"),Map.entry("target_address","Zieladresse"),Map.entry("vht_value","Fahrzeug"),Map.entry("jb_ordertime","Auftragszeit"),Map.entry("cr_sid","Kurier"),Map.entry("jb_taketime","Annahme"),Map.entry("jb_storno","Storno"),Map.entry("jb_finishtime","Erledigung"),Map.entry("tr_remark","Bemerkung"),Map.entry("jb_status","Status"),Map.entry("jb_tourname","Tourname"),Map.entry("jb_co2e","CO₂e kg"),Map.entry("jb_dispoinfo","Dispositionsinfo"),Map.entry("target_address2","Zieladresse mit Ort"),Map.entry("tr_finishtime","Abholung abgeschlossen"),Map.entry("tr_signname","Unterschrift Abholung"),Map.entry("tr2_finishtime","Zustellung abgeschlossen"),Map.entry("tr2_signname","Unterschrift Zustellung"),Map.entry("jb_freetext_1","Auftragshinweis"),Map.entry("jb_tourcities","Tourorte"),Map.entry("jb_tourzipcodes","Tour-PLZ")); + record Column(String key,String label,String type,String align){} + record Layout(List columns,Map expressions,String warning){} + final DatabaseService db; + CustomerPortal(DatabaseService db){this.db=db;} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @PostMapping("/detail") Object detail(Authentication a,@RequestBody Detail d){return detail(ApiController.user(a),d);} + String personal(LegacyUser u,String key){var values=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=? AND hq_id=0 AND par_key=?",u.employeeId(),key);if(values.size()>1)throw bad("Mehrdeutige persönliche Kundeneinstellung.");return values.isEmpty()?"":text(values.getFirst(),"par_value");} + String global(long hq,String key){return new OrderEntryCatalog(db).parameter(hq,key);} + boolean enabled(long hq,String key){return global(hq,key).equals("1");} + boolean bourse(LegacyUser u){return enabled(u.headquartersId(),"SYSTEM_BOURSE_MODE");} + Collection orderCustomers(LegacyUser u,CustomerPortalScope.Context c){return bourse(u)?List.of(CustomerPortalScope.customer(c,id(c.account(),"cs_id"))):c.customers().values();} + static final String BOURSE_PRICE="COALESCE((SELECT SUM(ts.trs_price*(100-ts.trs_discount)/100) FROM tourservice ts WHERE ts.jb_id=jb.jb_id),0)"; + static final String BOURSE_BID="(SELECT MIN(b.brs_bid_amount) FROM bourse_bid b WHERE b.jb_id=jb.jb_id)"; + void bourseReady(){if(db.queryForList("SELECT column_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bourse_bid' AND column_name IN ('jb_id','brs_bid_amount')").size()!=2)throw bad("Der Börsenmodus ist eingeschaltet, aber die Gebotstabelle ist nicht vollständig eingerichtet.");} + Layout bourseLayout(LegacyUser u,int status){ + var expr=OrderListCells.expressions(List.of(),u,false,false,status,null);expr.put("business_volume",BOURSE_PRICE);expr.put("brs_bid_amount_current",BOURSE_BID);expr.put("csc_name","(SELECT cc.csc_name FROM costcenter cc WHERE cc.csc_id=jb.csc_id_payer)");expr.put("target_address",OrderListCells.station(2,"CONCAT_WS(' ',a.ad_street,NULLIF(t.tr_hsno,''),a.ad_zipcode,a.ad_city)")); + var keys=new ArrayList<>(List.of("jb_id","business_volume"));if(status==9)keys.add("brs_bid_amount_current");keys.addAll(List.of("csc_name","tr_comp","tr_comp2","streethsno","ad_zipcode","target_address","vht_value","jb_ordertime","cr_sid")); + return new Layout(keys.stream().map(k->new Column(k,k.equals("business_volume")?"Preis":k.equals("brs_bid_amount_current")?"Akt. Gebot":LABELS.get(k),Set.of("business_volume","brs_bid_amount_current").contains(k)?"money":OrderListCells.type(k),Set.of("jb_id","business_volume","brs_bid_amount_current").contains(k)?"right":"left")).toList(),expr,""); + } + String payer(LegacyUser u){if(bourse(u))return "jb.csc_id_payer";if(enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))return "jb.csc_id_related";String cash=global(u.headquartersId(),"CSC_ID_PAYER_CASH");long id=0;try{if(!cash.isBlank())id=Long.parseLong(cash);}catch(NumberFormatException e){throw bad("Die Barzahler-Kostenstelle ist ungültig.");}if(id<0)throw bad("Die Barzahler-Kostenstelle ist ungültig.");return id>0?"CASE WHEN jb.csc_id_payer="+id+" THEN jb.csc_id ELSE jb.csc_id_payer END":"jb.csc_id_payer";} + Layout layout(LegacyUser u,long customer,boolean stations,int status,String payer){ + var expressions=OrderListCells.expressions(List.of(),u,false,false,status,null);expressions.put("tr2_commission_no",OrderListCells.station(2,"t.tr_commission_no"));expressions.put("csc_name","(SELECT cc.csc_name FROM costcenter cc WHERE cc.csc_id="+payer+")");expressions.put("target_address",OrderListCells.station(2,"CONCAT_WS(' ',a.ad_street,NULLIF(t.tr_hsno,''),a.ad_zipcode,a.ad_city)"));expressions.put("ad_city",OrderListCells.station(1,"a.ad_city"));expressions.put("tr_remark",OrderListCells.station(1,publicRemarkSql("t.tr_remark")));expressions.put("jb_storno","jb.jb_storno");expressions.put("jb_status","jb.jb_status"); + if(stations){for(String k:List.of("tr_comp","tr_comp2","tr_commission_no","tr_remark"))expressions.put(k,k.equals("tr_remark")?publicRemarkSql("st.tr_remark"):"st."+k);expressions.put("streethsno","CONCAT_WS(' ',sa.ad_street,NULLIF(st.tr_hsno,''))");expressions.put("ad_zipcode","sa.ad_zipcode");expressions.put("ad_city","sa.ad_city");} + CustomerPortalColumns.expressions(expressions,stations); + String configured=preference(u,customer,"FIELDS"),titles=preference(u,customer,"TITLES"),aligns=preference(u,customer,"ALIGNS"); + boolean custom=!stations&&!configured.isBlank()&&!titles.isBlank()&&!aligns.isBlank(); + var keys=new ArrayList<>(stations?STATIONS:custom?parts(configured):DEFAULT); + var names=new ArrayList<>(custom?parts(titles):keys.stream().map(LABELS::get).toList()); + var alignment=new ArrayList<>(custom?parts(aligns):keys.stream().map(k->OrderListCells.type(k).equals("money")?"r":"l").toList()); + if(!stations&&!custom){ + if(status!=2&&enabled(0,"CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"+customer)){keys.add("jb_taketime");names.add(LABELS.get("jb_taketime"));alignment.add("c");} + if(enabled(u.headquartersId(),"CUSTOMER_MASK_JOBLIST_DISPLAY_CANCELLED_JOBS")&&status==2||enabled(0,"CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_"+customer)&&status!=2){keys.add("jb_storno");names.add(LABELS.get("jb_storno"));alignment.add("c");} + } + String extra=personal(u,"CUSTOMER_MASK_JOBLIST_ADD_ON_FIELDS"); + if(!extra.isBlank()){ + // Missing optional labels must not shift the next column's metadata. + var extraKeys=parts(extra);var extraNames=parts(personal(u,"CUSTOMER_MASK_JOBLIST_ADD_ON_TITLES"));var extraAligns=parts(personal(u,"CUSTOMER_MASK_JOBLIST_ADD_ON_ALIGNS")); + while(names.size()keys.size())names.removeLast();while(alignment.size()>keys.size())alignment.removeLast(); + for(int i=0;i();boolean unknown=false; + for(int i=0;i parts(String value){return Arrays.stream(value.split(",",-1)).map(String::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);} + String membership(String payer,Set centers){return " FROM job jb WHERE ("+payer+") IN ("+CourierPortal.placeholders(centers.size())+") AND jb.hq_id IN ("+CourierBourse.HQ_SQL+")";} + Map query(LegacyUser u,Query q){return db.readTransaction(()->{ + var c=CustomerPortalScope.context(db,u);c.require(4);boolean bourse=bourse(u);if(bourse)bourseReady();if(q==null||!(bourse?Set.of(0,1,2,9):Set.of(0,2)).contains(q.status())||q.page()<0||q.page()>10000||q.sort()==null)throw bad("Ungültige Kunden-Auftragssuche.");if(bourse&&(q.customer()!=id(c.account(),"cs_id")||q.stations()))throw CustomerPortalScope.denied();var centers=CustomerPortalScope.centers(c,q.customer(),q.center(),q.descendants());String search=Objects.toString(q.search(),"").strip();String term=EntryCostCenterSearch.term(search,false);boolean date=bourse||search.isEmpty()||q.stations();if(date&&(q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen."); + String payer=payer(u);var layout=bourse?bourseLayout(u,q.status()):layout(u,q.customer(),q.stations(),q.status(),payer);if(!q.sort().equals("jb_ordertime")&&layout.columns().stream().noneMatch(col->col.key().equals(q.sort())))throw bad("Die Sortierspalte ist nicht freigegeben."); + var args=new ArrayList(centers);args.add(mandator(db,u));String base=membership(payer,centers);if(q.stations())base=base.replace(" FROM job jb WHERE "," FROM job jb JOIN tour st ON st.jb_id=jb.jb_id JOIN address sa ON sa.ad_id=st.ad_id WHERE ");var where=new StringBuilder(base);where.append(q.status()==2?" AND jb.jb_status=2":bourse?(q.status()==1?" AND jb.jb_status=1":" AND jb.jb_status IN (0,8,9)"):" AND jb.jb_status IN (0,1,8,9)");if(bourse)where.append(" AND EXISTS (SELECT 1 FROM tour firstStop JOIN address firstAddress ON firstAddress.ad_id=firstStop.ad_id WHERE firstStop.jb_id=jb.jb_id AND firstStop.tr_sort=1) AND EXISTS (SELECT 1 FROM metatype vt WHERE vt.mt_type='vehicletype' AND vt.mt_sort=jb.vht_id)");if(!bourse&&!enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))where.append(" AND COALESCE(jb.jb_id_parent,0)<=0"); + if(bourse||!(q.status()==2?enabled(u.headquartersId(),"CUSTOMER_MASK_JOBLIST_DISPLAY_CANCELLED_JOBS"):enabled(0,"CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_"+q.customer())))where.append(" AND COALESCE(jb.jb_storno,0)=0"); + if(date){where.append(" AND jb.jb_ordertime>=? AND jb.jb_ordertime summary=null;if(bourse&&q.status()==2)summary=db.queryForList("SELECT COUNT(*) AS count,COALESCE(SUM("+BOURSE_PRICE+"),0) AS amount"+where,args.toArray()).getFirst(); + else if(q.status()==2&&!q.stations()&&layout.columns().stream().anyMatch(col->col.key().equals("business_volume_with_toll"))){ + summary=new LinkedHashMap<>(db.queryForList("SELECT COUNT(*) AS count,COALESCE(SUM(CASE WHEN "+CustomerPendingPrice.SQL+" THEN 0 ELSE "+layout.expressions().get("business_volume_with_toll")+" END),0) AS amount,COALESCE(SUM(CASE WHEN "+CustomerPendingPrice.SQL+" THEN 1 ELSE 0 END),0) AS pendingCount"+where,args.toArray()).getFirst()); + summary.put("basis","NET_WITH_TOLL"); + } + var projection=new ArrayList();for(var col:layout.columns())projection.add(layout.expressions().get(col.key())+" AS cell_"+col.key());projection.addAll(CustomerPortalColumns.routeProjection(layout,q.stations()));args.add(PAGE+1);args.add(q.page()*PAGE); + var data=db.queryForList("SELECT jb.jb_id,"+(bourse?"0":CustomerPendingPrice.SQL)+" AS price_pending,"+(q.stations()?"st.tr_id,st.tr_sort,":"")+String.join(",",projection)+where+" ORDER BY "+layout.expressions().get(q.sort())+(q.descending()?" DESC":" ASC")+",jb.jb_id"+(q.stations()?",st.tr_sort,st.tr_id":"")+" LIMIT ? OFFSET ?",args.toArray()); + var rows=data.stream().limit(PAGE).map(r->{var cells=new LinkedHashMap();for(var col:layout.columns()){String v=CustomerPortalColumns.value(r,col);cells.put(col.key(),id(r,"price_pending")==1&&col.type().equals("money")?"":v);}return fields("pricePending",id(r,"price_pending")==1,"id",id(r,"jb_id"),"station",id(r,"tr_sort"),"key",id(r,"jb_id")+":"+id(r,"tr_id"),"cells",cells);}).toList();return fields("rows",rows,"columns",layout.columns(),"page",q.page(),"limit",PAGE,"hasNext",data.size()>PAGE,"dateApplied",date,"warning",layout.warning(),"summary",summary); + });} + Map detail(LegacyUser u,Detail d){return db.readTransaction(()->{ + var c=CustomerPortalScope.context(db,u);c.require(4);if(d==null||d.job()<=0)throw bad("Auftrag fehlt.");boolean bourse=bourse(u);if(bourse)bourseReady();var centers=new TreeSet();orderCustomers(u,c).forEach(cs->centers.addAll(cs.allowed()));var args=new ArrayList(centers);args.add(mandator(db,u));args.add(d.job());var rows=db.queryForList("SELECT "+(bourse?"0":CustomerPendingPrice.SQL)+" AS price_pending,jb.jb_id,jb.hq_id,jb.csc_id,jb.jb_payment+0 AS jb_payment,jb.csc_id_payer,jb.csc_id_payer_cash,jb.jb_status,jb.jb_storno,jb.jb_ordertime,jb.jb_finishtime,jb.jb_totalprice,jb.jb_invtext,jb.cr_sid"+(bourse?","+BOURSE_PRICE+" AS bourse_price,"+BOURSE_BID+" AS bourse_bid":"")+membership(payer(u),centers)+" AND jb.jb_id=?",args.toArray());if(rows.size()!=1)throw CustomerPortalScope.denied();var job=rows.getFirst(); + var stops=db.queryForList(STOP_SQL+" ORDER BY t.tr_sort,t.tr_id",d.job()); + return fields("stationNumbers",OrderDetailOptions.stationNumbers(db,id(job,"hq_id"),id(job,"csc_id_payer")),"canAssign",id(job,"hq_id")==u.headquartersId()&&Set.of(0L,1L,8L,9L).contains(id(job,"jb_status"))&&id(job,"jb_storno")==0&&!enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")&&enabled(0,"CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"+id(c.account(),"cs_id"))&&CustomerPortalScope.customer(c,id(c.account(),"cs_id")).allowed().contains(CustomerEntryTours.payer(job)),"canCopy",id(job,"hq_id")==u.headquartersId()&&c.customers().get(id(c.account(),"cs_id")).allowed().contains(CustomerEntryTours.payer(job))&&entryAvailable(u,c),"id",d.job(),"status",id(job,"jb_status"),"cancelled",id(job,"jb_storno"),"ordered",dateTime(job,"jb_ordertime"),"finished",dateTime(job,"jb_finishtime"),"pricePending",id(job,"price_pending")==1,"price",id(job,"price_pending")==1?null:BillingService.money(decimal(job,bourse?"bourse_price":"jb_totalprice")),"bourse",bourse,"bid",bourse&&job.get("bourse_bid")!=null?BillingService.money(decimal(job,"bourse_bid")):null,"vehicle",text(job,"cr_sid"),"invoiceText",id(job,"price_pending")==1?"":text(job,"jb_invtext"),"stops",publicStops(stops)); + });} + static String publicRemarkSql(String expression){return "REGEXP_REPLACE("+expression+",'(?s)[[][[].*?([]][]]|$)','')";} + static final String STOP_SQL="SELECT t.tr_sort,t.tr_comp,t.tr_comp2,t.tr_person,t.tr_phone,t.tr_remark,t.tr_commission_no,t.tr_hsno,t.tr_status,t.tr_finishtime,t.tr_signname,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=?"; + static List> publicStops(List> stops){return stops.stream().>map(s->fields("sequence",id(s,"tr_sort"),"company",text(s,"tr_comp"),"additional",text(s,"tr_comp2"),"street",text(s,"ad_street"),"houseNumber",text(s,"tr_hsno"),"zip",text(s,"ad_zipcode"),"city",text(s,"ad_city"),"country",text(s,"ad_country"),"person",text(s,"tr_person"),"phone",text(s,"tr_phone"),"remark",OrderDeliveryData.publicRemark(text(s,"tr_remark")),"commission",text(s,"tr_commission_no"),"completed",id(s,"tr_status")==1,"finished",dateTime(s,"tr_finishtime"),"recipient",text(s,"tr_signname"))).toList();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPortalColumns.java b/backend/src/main/java/de/stadtbote/votian/CustomerPortalColumns.java new file mode 100644 index 0000000..533dc45 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPortalColumns.java @@ -0,0 +1,52 @@ +package de.stadtbote.votian; + +import java.util.*; +import java.math.BigDecimal; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Public, explicitly configured customer columns from customer/jb_list.php. */ +final class CustomerPortalColumns { + private CustomerPortalColumns() {} + static final Set FINISHED=Set.of("tr_finishtime","tr2_finishtime"); + static final Set ROUTE=Set.of("jb_tourcities","jb_tourzipcodes"); + static String type(String key){return FINISHED.contains(key)?"date":key.equals("jb_storno")?"text":OrderListCells.type(key);} + static void grossPrice(Map e,String configuredTax){ + String tax; + try{tax=new BigDecimal(configuredTax.strip()).toPlainString();}catch(NumberFormatException ex){tax="19";} + // PHP taxes the stored customer amount, then adds the toll without taxing it again. + e.put("business_volume_gross_with_toll","(jb.jb_totalprice*(1+("+tax+")/100))+("+e.get("gdc_toll_content")+")"); + } + static void expressions(Map e,boolean stations){ + for(String key:List.of("jb_dispoinfo","jb_freetext_1"))e.put(key,CustomerPortal.publicRemarkSql("jb."+key)); + for(String key:List.of("tr_finishtime","tr_signname")){ + e.put(key,stations?"st."+key:OrderListCells.station(1,"t."+key)); + e.put(key.replace("tr_","tr2_"),OrderListCells.station(2,"t."+key)); + } + e.put("target_address",OrderListCells.station(2,"CONCAT_WS(', ',CONCAT_WS(' ',a.ad_street,NULLIF(t.tr_hsno,'')),a.ad_zipcode)")); + e.put("target_address2",OrderListCells.station(2,"CONCAT_WS(', ',CONCAT_WS(' ',a.ad_street,NULLIF(t.tr_hsno,'')),a.ad_zipcode,a.ad_city)")); + } + static List routeProjection(CustomerPortal.Layout layout,boolean stations){ + if(layout.columns().stream().noneMatch(c->ROUTE.contains(c.key())))return List.of(); + var result=new ArrayList(); + for(String key:List.of("ad_zipcode","ad_city")){ + result.add((stations?"sa."+key:OrderListCells.station(1,"a."+key))+" AS route_start_"+key); + result.add(OrderListCells.station(2,"a."+key)+" AS route_end_"+key); + } + return result; + } + static String value(Map row,CustomerPortal.Column col){ + String key=col.key(),v=text(row,"cell_"+key); + if(col.type().equals("date")){var d=dateTime(row,"cell_"+key);return d==null?"":d.toString();} + if(!ROUTE.contains(key))return v; + String[] sections=v.split("\\|",-1); + if(sections.length>=3){ + if(key.equals("jb_tourcities"))return sections[1].strip().replaceAll(";+$","").replace(";",", "); + String codes=sections[0].strip().replaceAll(";+$","");if(codes.isEmpty())return ""; + String[] zips=codes.split(";",-1),countries=sections[sections.length-1].strip().split(";",-1);var formatted=new ArrayList(); + for(int i=0;i'' AND COALESCE(h.hq_disabled,0)=0 AND e.csc_id>0 AND (SELECT COUNT(*) FROM employee allEmp WHERE allEmp.usr_id=u.usr_id)=1 AND NOT EXISTS (SELECT 1 FROM courier cr WHERE cr.usr_id=u.usr_id) AND (SELECT COUNT(*) FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id)=1 AND EXISTS (SELECT 1 FROM mandatorheadquarters positive WHERE positive.hq_id=u.hq_id AND positive.md_id>0)"; + record Customer(long id,long root,long base,String name,List> centers,Set allowed){} + record Context(Map account,Map customers,String rights){boolean can(int bit){return EmployeeAdministration.bit(rights,bit);}void require(int bit){if(!can(bit))throw denied();}} + static org.springframework.security.access.AccessDeniedException denied(){return new org.springframework.security.access.AccessDeniedException("Diese Daten oder Aktion sind für den Kundenzugang nicht freigegeben.");} + static Map verify(DatabaseService db,LegacyUser u){if(!u.customerAccount()||u.employeeId()<=0||u.headquartersId()!=u.originalHeadquartersId())throw denied();var rows=db.queryForList(ACCOUNT_SQL,u.userId(),u.employeeId(),u.originalHeadquartersId());if(rows.size()!=1)throw denied();return rows.getFirst();} + static Context context(DatabaseService db,LegacyUser u){ + var account=verify(db,u);String live=text(account,"emp_rights");var rights=new StringBuilder();for(int n=0;n<16;n++)rights.append(u.customerRight(n)&&EmployeeAdministration.bit(live,n)?'1':'0'); + long own=id(account,"cs_id"),base=id(account,"csc_id"),root=id(account,"customer_root");boolean navigate=EmployeeAdministration.bit(rights.toString(),0); + var ids=new ArrayList>();ids.add(fields("cs_id",own,"csc_id",root,"cmp_id",id(account,"cmp_id"))); + if(navigate&&base==root)ids.addAll(db.queryForList("SELECT cs_id,csc_id,cmp_id FROM customer WHERE cs_id_parent=? AND cs_id<>? AND hq_id IN ("+CourierBourse.HQ_SQL+") ORDER BY cs_id",own,own,mandator(db,u))); + if(ids.size()>501)throw bad("Der Kundenverbund ist zu groß. Bitte die Zuordnung prüfen.");var customers=new LinkedHashMap(); + for(var row:ids){long cs=id(row,"cs_id"),csRoot=id(row,"csc_id"),csBase=cs==own?base:csRoot;var centers=db.queryForList("SELECT csc_id AS id,csc_pre_id AS parent,csc_name AS label,csc_visible AS visible,csc_blocked AS blocked FROM costcenter WHERE cs_id=? ORDER BY csc_name,csc_id LIMIT 10001",cs);if(centers.size()>10000)throw bad("Zu viele Kostenstellen für die Kundenansicht.");var valid=PortalEmployees.validCenters(centers,csRoot);if(!valid.contains(csBase))throw bad("Die Kunden-Kostenstellenzuordnung ist unvollständig oder zyklisch.");var subtree=PortalEmployees.validCenters(centers,csBase);var allowed=new HashSet(navigate?subtree:Set.of(csBase));allowed.retainAll(valid);var names=db.queryForList("SELECT cmp_comp FROM company WHERE cmp_id=?",id(row,"cmp_id"));String name=names.size()==1?text(names.getFirst(),"cmp_comp"):"Kunde "+cs; + customers.put(cs,new Customer(cs,csRoot,csBase,name,centers.stream().filter(c->allowed.contains(id(c,"id"))).toList(),Set.copyOf(allowed))); + } + return new Context(account,customers,rights.toString()); + } + static Customer customer(Context context,long id){var c=context.customers().get(id);if(c==null)throw denied();return c;} + static Set centers(Context context,long customer,long center,boolean descendants){var c=customer(context,customer);if(!c.allowed().contains(center))throw denied();if(!descendants)return Set.of(center);if(!context.can(0)&¢er!=c.base())throw denied();var ids=new HashSet<>(PortalEmployees.validCenters(c.centers(),center));ids.retainAll(c.allowed());return ids;} + static boolean allowed(String method,String path){if(method.equals("GET")&&path.equals("/api/historical-orders"))return true;if(method.equals("POST")&&Set.of("/api/historical-orders/query","/api/historical-orders/detail","/api/historical-orders/statistics").contains(path))return true;if(method.equals("GET")&&path.equals("/api/customer-portal/info"))return true;if(Set.of("GET","PUT").contains(method)&&path.equals("/api/account/language"))return true;if(Set.of("GET","POST").contains(method)&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/(texts|edit)"))return true;if(method.equals("POST")&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/(texts|edit)/preview"))return true;if(method.equals("GET")&&path.equals("/api/customer-portal/map"))return true;if(Set.of("GET","POST").contains(method)&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/courier-assignment"))return true;if(method.equals("POST")&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/courier-assignment/preview"))return true;if(method.equals("GET")&&(path.equals("/api/customer-portal/entry")||path.equals("/api/customer-portal/entry/tours")||path.matches("/api/customer-portal/entry/tours/[1-9][0-9]*")||path.matches("/api/customer-portal/entry/addresses/[1-9][0-9]*")))return true;if(method.equals("POST")&&Set.of("/api/customer-portal/entry","/api/customer-portal/entry/quote").contains(path))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/labels(/(OUTBOUND|RETURN)(/(pdf|page))?)?"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/(route|history|evidence|pdf|pdf-page|stations/[1-9][0-9]*/photos/(pic|b2b)-[1-9][0-9]{0,18})"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/photos(/(file-[0-9a-f]{64}|b2b-[1-9][0-9]{0,18}))?"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/items"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/export-removal/[1-9][0-9]*"))return true;if(method.equals("POST")&&path.matches("/api/customer-portal/export-removal/[0-9a-f-]{36}/(resume|restore)"))return true;if(method.equals("POST")&&path.matches("/api/customer-portal/export-storage/[0-9a-f-]{36}/(resume|cancel)"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/export-files/[1-9][0-9]*/download"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/exports/(110|115|120)/[0-9]+"))return true;if(Set.of("GET","POST").contains(method)&&path.matches("/api/customer-portal/orders/[1-9][0-9]*/cancellation"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/employees/[1-9][0-9]*/[1-9][0-9]*"))return true;if(method.equals("GET")&&path.matches("/api/customer-portal/centers/[1-9][0-9]*/[1-9][0-9]*(/(address-suggestions|address-details))?"))return true;if(method.equals("GET"))return Set.of("/api/me","/api/system","/api/csrf","/api/health","/api/customer-portal","/api/customer-portal/two-factor","/api/customer-portal/statement","/api/customer-portal/statistics","/api/customer-portal/exports","/api/customer-portal/export-files","/api/customer-portal/export-storage","/api/customer-portal/export-removal").contains(path);return method.equals("POST")&&Set.of("/api/logout","/api/customer-portal/two-factor/setup","/api/customer-portal/two-factor/activate","/api/customer-portal/two-factor/cancel","/api/customer-portal/two-factor/reset","/api/customer-portal/query","/api/customer-portal/detail","/api/customer-portal/centers/preview","/api/customer-portal/centers/save","/api/customer-portal/employees/query","/api/customer-portal/employees/save","/api/customer-portal/statement","/api/customer-portal/statement/detail","/api/customer-portal/statistics/query","/api/customer-portal/statistics/download","/api/customer-portal/exports/preview","/api/customer-portal/exports/download","/api/customer-portal/exports/save","/api/customer-portal/export-storage","/api/customer-portal/export-removal").contains(path);} + private CustomerPortalScope(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerPreviousNumber.java b/backend/src/main/java/de/stadtbote/votian/CustomerPreviousNumber.java new file mode 100644 index 0000000..09d71bb --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerPreviousNumber.java @@ -0,0 +1,83 @@ +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.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Previous ExtID and customerchanged are a single customer rename relationship. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/previous-number") +class CustomerPreviousNumber { + final DatabaseService db;final BusinessSupport support; + CustomerPreviousNumber(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + record Draft(String revision,String previousNumber){} + record Command(String requestId,Draft draft,String fingerprint){} + record View(long customer,String label,String revision,String previousNumber,boolean linked){} + record Preview(String fingerprint,String before,String after,String target,String effect){} + record State(Map owner,List> numbers,List> links,View view){} + record Plan(Preview preview,Map target){} + @GetMapping View get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);} + @PostMapping("/preview") Preview check(Authentication a,@PathVariable long customer,@RequestBody Draft d){return preview(ApiController.user(a),customer,d);} + @PostMapping Map post(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + LegacyUser access(LegacyUser actor){actor.require(0);var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);return u;} + State state(LegacyUser u,long customer,boolean lock){ + if(customer<=0)throw bad("Ungültiger Kunde."); + String sql="SELECT customer.cs_id,customer.cs_eid,customer.hq_id,customer.cmp_id,company.cmp_comp,company.cmp_comp2 FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE customer.cs_id=? AND customer.hq_id=?"; + var owner=one(lock?db.lockRows(sql,customer,u.headquartersId()):db.queryForList(sql,customer,u.headquartersId())); + var numbers=numberRows(db,customer,lock);var links=linkRows(db,customer,lock);if(numbers.size()>1||links.size()>1)throw bad("Mehrdeutige Umfirmierungszuordnung. Bitte den Bestand klären."); + String number=numbers.isEmpty()?"":text(numbers.getFirst(),"gdc_content"); + return new State(owner,numbers,links,new View(customer,text(owner,"cs_eid")+" · "+text(owner,"cmp_comp"),revision(List.of(owner,numbers,links)),number,!links.isEmpty())); + } + static List> numberRows(DatabaseService db,long customer,boolean lock){String sql="SELECT * FROM genericdatacontainer WHERE gdc_obj_type='cs' AND gdc_obj_id=? AND gdc_gen_fieldname='eid_old' ORDER BY gdc_id";return lock?db.lockRows(sql,customer):db.queryForList(sql,customer);} + static List> linkRows(DatabaseService db,long customer,boolean lock){String sql="SELECT cs_id,cs_eid,cs_id_old,cs_eid_old FROM customerchanged WHERE cs_id=? ORDER BY cs_id_old";return lock?db.lockRows(sql,customer):db.queryForList(sql,customer);} + static String normalize(String number){String value=number==null?"":number.strip();if(value.length()>10||value.chars().anyMatch(Character::isISOControl))throw bad("Die alte Kundennummer darf höchstens 10 Zeichen enthalten.");return value;} + static void validate(Draft d){if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Bestandsrevision fehlt.");normalize(d.previousNumber());} + View view(LegacyUser actor,long customer){actor.require(0);return db.readTransaction(()->state(access(actor),customer,false).view());} + Plan prepare(LegacyUser u,long customer,Draft d,State s,boolean lock){ + if(!s.view().revision().equals(d.revision()))throw conflict();String number=normalize(d.previousNumber()); + var scope=MasterDirectoryScope.access(db,u,0);var hqs=scope.branches().stream().map(r->id(r,"id")).toList(); + List> found=List.of();if(!number.isEmpty()){ + var args=new ArrayList();args.add(number);args.addAll(hqs);String sql="SELECT customer.cs_id,customer.cs_eid,customer.hq_id,company.cmp_comp FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE customer.cs_eid=? AND customer.hq_id IN ("+CrmScope.marks(hqs.size())+") ORDER BY customer.cs_id LIMIT 2"; + found=lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());if(found.size()>1)throw bad("Die alte Kundennummer ist nicht eindeutig."); + } + var target=found.isEmpty()?Map.of():found.getFirst();var chain=new ArrayList>(); + if(!target.isEmpty()){ + long source=id(target,"cs_id");if(source==customer)throw bad("Ein Kunde darf nicht sein eigener Vorgänger sein."); + String assignedSql="SELECT cs_id,cs_id_old FROM customerchanged WHERE cs_id_old=? AND cs_id<>? ORDER BY cs_id";var assigned=lock?db.lockRows(assignedSql,source,customer):db.queryForList(assignedSql,source,customer);if(!assigned.isEmpty())throw bad("Dieser Vorgänger ist bereits einem anderen Kunden zugeordnet."); + var seen=new HashSet();seen.add(customer);long cursor=source; + while(cursor>0){if(!seen.add(cursor))throw bad("Die Umfirmierungszuordnung würde einen Kreis bilden.");if(seen.size()>1000)throw bad("Die Umfirmierungskette ist zu lang. Bitte den Bestand prüfen.");var rows=linkRows(db,cursor,lock);if(rows.size()>1)throw bad("Die Vorgängerkette ist mehrdeutig.");if(rows.isEmpty())break;chain.addAll(rows);cursor=id(rows.getFirst(),"cs_id_old");} + }else if(!number.isEmpty()&&!s.links().isEmpty()&&number.equals(s.view().previousNumber()))throw bad("Der bisherige Vorgänger ist nicht mehr eindeutig zugänglich. Bitte Berechtigung und Kundennummer prüfen."); + String label=target.isEmpty()?"":text(target,"cs_eid")+" · "+text(target,"cmp_comp"); + String effect=number.isEmpty()?"Alte Nummer und Umfirmierungszuordnung entfernen.":target.isEmpty()?"Kein zugänglicher Kunde mit dieser Nummer gefunden. Die Nummer wird als Hinweis gespeichert; eine bisherige Umfirmierungszuordnung wird entfernt.":"Vorgängerkunde zuordnen. Auswertungen können beide Kundennummern zusammenfassen. Aufträge, Rechnungen und Stammdaten des Vorgängers werden nicht verschoben."; + return new Plan(new Preview(revision(List.of(u.headquartersId(),customer,d,s.view().revision(),target,chain)),s.view().previousNumber(),number,label,effect),target); + } + Preview preview(LegacyUser actor,long customer,Draft d){actor.require(0);validate(d);return db.readTransaction(()->{var u=access(actor);return prepare(u,customer,d,state(u,customer,false),false).preview();});} + Map save(LegacyUser actor,long customer,Command c){ + actor.require(0);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen.");var initial=access(actor);var hqs=db.readTransaction(()->MasterDirectoryScope.access(db,initial,0).branches().stream().map(r->id(r,"id")).sorted().toList());support.transactional("customer","company","customerchanged","genericdatacontainer","headquarters"); + return db.writeTransaction(()->{ + db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id IN ("+CrmScope.marks(hqs.size())+") ORDER BY hq_id",hqs.toArray()); + var u=access(actor);var current=MasterDirectoryScope.access(db,u,0).branches().stream().map(r->id(r,"id")).sorted().toList();if(!current.equals(hqs))throw conflict(); + var s=state(u,customer,true);var receipt=List.of("CUSTOMER_PREVIOUS_NUMBER",customer,c.draft(),c.fingerprint());var prior=support.receipt(u,c.requestId(),receipt);if(prior!=null)return fields("changed",prior,"repeated",true); + var plan=prepare(u,customer,c.draft(),s,true);if(!plan.preview().fingerprint().equals(c.fingerprint()))throw conflict(); + String number=plan.preview().after();long source=plan.target().isEmpty()?0:id(plan.target(),"cs_id"); + boolean mappingChanged=source==0?!s.links().isEmpty():s.links().isEmpty()||id(s.links().getFirst(),"cs_id_old")!=source||!text(s.links().getFirst(),"cs_eid").equals(text(s.owner(),"cs_eid"))||!text(s.links().getFirst(),"cs_eid_old").equals(number); + boolean changed=!number.equals(s.view().previousNumber())||mappingChanged; + if(changed){ + if(s.numbers().isEmpty()){if(!number.isEmpty())db.insert("genericdatacontainer",fields("gdc_obj_type","cs","gdc_obj_id",customer,"gdc_gen_fieldname","eid_old","gdc_content",number,"gdc_context",LocalDateTime.now().toString()));} + else db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",number,LocalDateTime.now().toString(),id(s.numbers().getFirst(),"gdc_id")); + if(mappingChanged){db.update("DELETE FROM customerchanged WHERE cs_id=?",customer);if(source>0)BusinessSupport.insertWithoutKey(db,"customerchanged",fields("cs_id",customer,"cs_eid",text(s.owner(),"cs_eid"),"cs_id_old",source,"cs_eid_old",number));} + support.audit(u,0,customer,0,203,"Alte Kundennummer: "+s.view().previousNumber()+" → "+number,c.requestId()); + } + support.receipt(u,c.requestId(),receipt,changed?1:0);return fields("changed",changed?1:0,"repeated",false); + }); + } + static org.springframework.web.server.ResponseStatusException conflict(){return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Umfirmierungsdaten wurden inzwischen geändert. Bitte neu laden und erneut prüfen.");} + static void refreshNumber(DatabaseService db,BusinessSupport support,long customer,String number){ + if(numberRows(db,customer,true).isEmpty())return;support.transactional("customerchanged");var rows=linkRows(db,customer,true);if(rows.size()>1)throw bad("Mehrdeutige Umfirmierungszuordnung.");if(!rows.isEmpty())db.update("UPDATE customerchanged SET cs_eid=? WHERE cs_id=?",number,customer); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java b/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java new file mode 100644 index 0000000..a7daa9d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerReferences.java @@ -0,0 +1,62 @@ +package de.stadtbote.votian; + +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; + +/** Customer and payer selection in customer_special.php / cs_csc_list.php. */ +@RestController +@RequestMapping("/api/master-data/customer-references") +class CustomerReferences { + static final Set KEYS=Set.of("csc_id_payer","cs_id_parent"); + static final List FIELDS=List.of( + MasterData.f("csc_id_payer","Drittzahler","Abrechnung","payerReference",0,false), + MasterData.f("cs_id_parent","Übergeordneter Kunde","Vertrag und Status","customerReference",0,false)); + final DatabaseService db; + CustomerReferences(DatabaseService db){this.db=db;} + @GetMapping("/{kind}") Object find(Authentication a,@PathVariable String kind,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="0") long headquarters,@RequestParam(defaultValue="false") boolean hidden,@RequestParam(defaultValue="false") boolean prospects,@RequestParam(defaultValue="false") boolean prefix,@RequestParam(defaultValue="0") int offset){return search(ApiController.user(a),kind,q,headquarters,hidden,prospects,prefix,offset);} + @GetMapping("/{kind}/{id}") Object get(Authentication a,@PathVariable String kind,@PathVariable long id){return selected(ApiController.user(a),kind,id);} + static boolean payer(String kind){if(!Set.of("payer","parent").contains(kind))throw bad("Unbekannte Kundenreferenz.");return kind.equals("payer");} + LegacyUser access(LegacyUser actor){actor.require(0);var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(0);return u;} + List> scope(LegacyUser u){ + var catalog=new OrderEntryCatalog(db);String disabled=catalog.parameter(u.headquartersId(),"MASK_CSC_LIST_HQ_SELECTION_DISABLED");if(disabled.isBlank())disabled=catalog.parameter(0,"MASK_CSC_LIST_HQ_SELECTION_DISABLED"); + if(!u.hasRight(10)||disabled.equals("1"))return List.of(fields("id",u.headquartersId(),"label","Aktuelle Niederlassung")); + return MasterDirectoryScope.access(db,u,0).branches(); + } + static String from(boolean payer){return " FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id"+(payer?" JOIN costcenter cc ON cc.cs_id=cs.cs_id LEFT JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=4 LEFT JOIN address ad ON ad.ad_id=ca.ad_id":"")+" WHERE COALESCE(cmp.cmp_archived,0)=0"+(payer?" AND cc.csc_is_extern=0":"");} + static String columns(boolean payer){return "SELECT "+(payer?"cc.csc_id":"cs.cs_id")+" AS id,cs.cs_id AS customer,cs.cs_eid AS number,cmp.cmp_comp AS name,cs.hq_id AS headquarters,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_type"+(payer?",cc.csc_name AS costCenter,cc.csc_visible,cc.csc_blocked,COALESCE(ad.ad_zipcode,'') AS postcode,COALESCE(ad.ad_city,'') AS city":",'' AS costCenter,'' AS postcode,'' AS city");} + static Map display(Map r){ + var warnings=new ArrayList();if(id(r,"cmp_visible")!=1)warnings.add("Kunde ausgeblendet");if(id(r,"cmp_authenticated")!=1)warnings.add("Kunde nicht freigegeben");if(id(r,"cmp_type")!=0)warnings.add("Kein regulärer Kunde");if(r.containsKey("csc_visible")&&id(r,"csc_visible")!=1)warnings.add("Kostenstelle ausgeblendet");if(id(r,"csc_blocked")!=0)warnings.add("Kostenstelle gesperrt"); + return fields("id",id(r,"id"),"customer",id(r,"customer"),"number",text(r,"number"),"name",text(r,"name"),"headquarters",id(r,"headquarters"),"costCenter",text(r,"costCenter"),"postcode",text(r,"postcode"),"city",text(r,"city"),"warning",String.join(" · ",warnings)); + } + Map search(LegacyUser actor,String kind,String q,long headquarters,boolean hidden,boolean prospects,boolean prefix,int offset){ + actor.require(0);boolean payer=payer(kind);String term=EntryCostCenterSearch.term(q,prefix);if(offset<0||offset>1000000)throw bad("Ungültige Suchseite."); + 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); + 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); + }); + } + Map selected(LegacyUser actor,String kind,long target){actor.require(0);boolean payer=payer(kind);return db.readTransaction(()->{var u=access(actor);return display(reference(payer,target,scope(u),false));});} + Map reference(boolean payer,long target,List> branches,boolean lock){ + if(target<=0)throw bad("Bitte eine Kundenreferenz wählen.");var args=new ArrayList();args.add(target);branches.forEach(r->args.add(id(r,"id"))); + String sql=columns(payer)+from(payer)+" AND "+(payer?"cc.csc_id":"cs.cs_id")+"=? AND cs.hq_id IN ("+CrmScope.marks(branches.size())+")"; + var rows=lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());if(rows.size()!=1)throw bad("Die Kundenreferenz ist nicht mehr eindeutig zugänglich. Bitte neu auswählen.");return rows.getFirst(); + } + void prepare(LegacyUser actor,long customer,Map old,Map clean){ + for(String key:KEYS)clean.putIfAbsent(key,id(old,key));if(KEYS.stream().allMatch(k->id(old,k)==id(clean,k)))return; + var u=access(actor);var branches=scope(u); + if(id(clean,"csc_id_payer")>0&&id(clean,"csc_id_payer")!=id(old,"csc_id_payer"))reference(true,id(clean,"csc_id_payer"),branches,true); + long parent=id(clean,"cs_id_parent");if(parent>0&&parent!=id(old,"cs_id_parent")){ + reference(false,parent,branches,true);var seen=new HashSet();if(customer>0)seen.add(customer);long cursor=parent; + while(cursor>0){if(!seen.add(cursor))throw bad("Die Kundenzuordnung würde einen Kreis bilden.");if(seen.size()>1000)throw bad("Die Kundenhierarchie ist zu lang.");var rows=db.lockRows("SELECT cs_id,cs_id_parent FROM customer WHERE cs_id=?",cursor);if(rows.size()!=1)throw bad("Die übergeordnete Kundenhierarchie ist unvollständig.");cursor=id(rows.getFirst(),"cs_id_parent");} + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerRollcardMailSettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerRollcardMailSettings.java new file mode 100644 index 0000000..32c471d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerRollcardMailSettings.java @@ -0,0 +1,93 @@ +package de.stadtbote.votian; + +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; + +/** Shared customer mail parameters used by manual PHP/NG rollcards and the existing PHP cron. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/rollcard-mail-settings") +class CustomerRollcardMailSettings { + static final List SUFFIXES=List.of("SENDER","TO","CC","BCC"); + static final String IMPACT="Diese Adressen werden auch vom bestehenden automatischen PHP-Versand verwendet. Ein laufender Dienst kann zusätzliche Empfänger aus dem Fahrzeughalterstamm ergänzen und reservierte Aufträge als angenommen zuordnen. Der aktuelle PHP-Code berücksichtigt dabei weder die Versandzeit noch bereits erfolgte Sendungen als Sperre. Ob der Dienst läuft, wird hier nicht geprüft."; + final DatabaseService db; + final BusinessSupport support; + CustomerRollcardMailSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + record Values(String sender,String to,String cc,String bcc){} + enum Action { SET, REMOVE } + record Draft(String revision,Action action,Values values){} + record Command(String requestId,Draft draft,String fingerprint,boolean sharedUseConfirmed){} + record View(long customer,String label,String revision,boolean configured,boolean ready,Values values,List warnings,String impact){} + record Preview(String fingerprint,String customer,Values before,Values after,boolean remove,String impact){} + record State(View view,List> rows){} + static String key(String suffix,long customer){return "MAIL_CARTAGE_NOTE_MAIL_"+suffix+"_"+customer;} + static List keys(long customer){return SUFFIXES.stream().map(s->key(s,customer)).toList();} + static void access(LegacyUser u){u.require(0);u.require(2);} + @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 d){return preview(ApiController.user(a),customer,d);} + @PostMapping Map save(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + Map owner(LegacyUser u,long customer){if(customer<=0)throw bad("Ungültiger Kunde.");return one(db.queryForList("SELECT cs_id,cs_eid,customer.hq_id,customer.cmp_id,cmp_comp,cmp_comp2 FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE cs_id=? AND customer.hq_id=?",customer,u.headquartersId()));} + State state(LegacyUser u,long customer){ + var owner=owner(u,customer);var keys=keys(customer); + 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 (?,?,?,?) ORDER BY par_key,par_id",keys.toArray()); + var byKey=new HashMap(); + for(var r:rows){ + if(id(r,"md_id")!=0||id(r,"hq_id")!=0||id(r,"emp_id")!=0)throw bad("Die Rollkarten-Mailvorgaben haben einen abweichenden Geltungsbereich. Bitte den Parameterbestand klären lassen."); + if(byKey.put(text(r,"par_key"),text(r,"par_value"))!=null)throw bad("Mehrdeutige Rollkarten-Mailvorgaben. Bitte doppelte Parameter klären lassen."); + } + var v=new Values(byKey.getOrDefault(key("SENDER",customer),""),byKey.getOrDefault(key("TO",customer),""),byKey.getOrDefault(key("CC",customer),""),byKey.getOrDefault(key("BCC",customer),"")); + var warnings=new ArrayList();boolean ready=false; + if(rows.isEmpty())warnings.add("Für diesen Kunden sind noch keine Rollkarten-Mailadressen hinterlegt."); + else try{normalize(v);ready=true;}catch(org.springframework.web.server.ResponseStatusException e){warnings.add("Bestehende Vorgaben müssen korrigiert werden: "+e.getReason());} + String label=String.join(" · ",List.of(text(owner,"cs_eid"),text(owner,"cmp_comp"),text(owner,"cmp_comp2")).stream().filter(s->!s.isBlank()).toList()); + return new State(new View(customer,label,revision(List.of(owner,rows)),!rows.isEmpty(),ready,v,List.copyOf(warnings),IMPACT),rows); + } + View view(LegacyUser u,long customer){access(u);return db.readTransaction(()->state(u,customer).view());} + static Values normalize(Values v){ + if(v==null)throw bad("Mailadressen fehlen."); + var e=VehicleRollcardMail.envelope(v.sender(),v.to(),v.cc(),v.bcc()); + var all=new ArrayList();all.add(e.from());all.addAll(e.to());all.addAll(e.cc());all.addAll(e.bcc()); + // The shared PHP consumer places these values inside double-quoted shell arguments. + // Store no quoting, expansion or control characters; never escape them into another address. + for(String address:all)if(address.chars().anyMatch(ch->ch<32||ch==127||ch=='"'||ch==92||ch=='$'||ch=='`'))throw bad("Diese Adresse enthält Zeichen, die der bestehende PHP-Rollkartenversand nicht sicher verarbeiten kann."); + return new Values(e.from(),String.join(",",e.to()),String.join(",",e.cc()),String.join(",",e.bcc())); + } + static Draft validate(Draft d){ + if(d==null||d.action()==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Bestandsrevision oder Aktion fehlen."); + if(d.action()==Action.REMOVE){if(d.values()!=null)throw bad("Beim Entfernen dürfen keine Ersatzadressen angegeben sein.");} + else normalize(d.values()); + return d; + } + Preview prepare(LegacyUser u,long customer,Draft d,State s){ + if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Rollkarten-Mailvorgaben wurden inzwischen geändert. Bitte neu laden."); + var after=d.action()==Action.REMOVE?new Values("","","",""):normalize(d.values()); + return new Preview(revision(List.of(u.headquartersId(),customer,d,s.view().revision(),IMPACT)),s.view().label(),s.view().values(),after,d.action()==Action.REMOVE,IMPACT); + } + Preview preview(LegacyUser u,long customer,Draft input){access(u);var d=validate(input);return db.readTransaction(()->prepare(u,customer,d,state(u,customer)));} + Map save(LegacyUser u,long customer,Command c){ + access(u);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft()); + if(!c.sharedUseConfirmed())throw bad("Bitte die Auswirkungen auf den manuellen und automatischen Rollkartenversand samt Auftragszuordnung bestätigen."); + if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen."); + var receipt=List.of("CUSTOMER_ROLLCARD_MAIL",customer,d,c.fingerprint()); + return db.customerParameterWriteTransaction(()->{ + owner(u,customer);var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true); + var s=state(u,customer);var p=prepare(u,customer,d,s); + if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Änderungsvorschau passt nicht zu diesen Angaben."); + var values=List.of(p.after().sender(),p.after().to(),p.after().cc(),p.after().bcc());int changed=0; + for(int i=0;iname.equals(text(r,"par_key"))).findFirst().orElse(null); + if(d.action()==Action.REMOVE){if(old!=null){db.update("DELETE FROM parameter WHERE par_id=?",id(old,"par_id"));changed++;}} + else if(old==null){db.insert("parameter",fields("par_key",name,"md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",""));changed++;} + else if(!value.equals(text(old,"par_value"))){db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id"));changed++;} + } + if(changed>0)support.audit(u,0,customer,0,203,"Rollkarten-Mailvorgaben "+(p.remove()?"entfernt":"geändert")+" · Auswirkungen auf bestehenden PHP-Versand samt Auftragszuordnung bestätigt",c.requestId()); + support.receipt(u,c.requestId(),receipt,changed>0?1:0); + return fields("changed",changed>0?1:0,"repeated",false); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java b/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java new file mode 100644 index 0000000..c733e23 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerServiceDocuments.java @@ -0,0 +1,179 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.jsoup.Jsoup; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.BusinessSupport.token; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.CrmScope.marks; + +/** Customer-bound forms and device configuration from the current PHP service dialogs. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/service-documents") +class CustomerServiceDocuments { + static final String QUESTIONS="service_acceptance_protocol_questions"; + static final String MAIL="service_acceptance_protocol_mail_state_question"; + static final String FOOTER="service_acceptance_protocol_text"; + static final String REASONS="service_delivery_reasons"; + static final List PROTOCOL=List.of(QUESTIONS,MAIL,FOOTER); + final DatabaseService db; + final BusinessSupport support; + final FormAdministration forms; + final NewsletterTemplate templates; + final FormFieldSupport special; + CustomerServiceDocuments(DatabaseService db,BusinessSupport support,FormAdministration forms,NewsletterTemplate templates){this.db=db;this.support=support;this.forms=forms;this.templates=templates;this.special=new FormFieldSupport(db);} + record Entry(String code,String text,String mail) {} + record Save(String requestId,String revision,String schemaRevision,List entries,String footer) {} + record FormSave(String requestId,String revision,String schemaRevision,Map values) {} + record FormState(long scope,List> definitions,Map category,String template) {} + + @GetMapping Map options(Authentication a,@PathVariable long customer){return options(ApiController.user(a),customer);} + @GetMapping("/{kind:protocol|reasons}/{number}") Map get(Authentication a,@PathVariable long customer,@PathVariable String kind,@PathVariable long number){return view(ApiController.user(a),customer,kind,number);} + @PutMapping("/{kind:protocol|reasons}/{number}") Map put(Authentication a,@PathVariable long customer,@PathVariable String kind,@PathVariable long number,@RequestBody Save s){return save(ApiController.user(a),customer,kind,number,s);} + @GetMapping("/prices/{category}") Map form(Authentication a,@PathVariable long customer,@PathVariable long category){return form(ApiController.user(a),customer,category);} + @PutMapping("/prices/{category}") Map formSave(Authentication a,@PathVariable long customer,@PathVariable long category,@RequestBody FormSave s){return saveForm(ApiController.user(a),customer,category,s);} + @GetMapping("/prices/{category}/fields/{field}/options") + Map choices(Authentication a,@PathVariable long customer,@PathVariable long category,@PathVariable long field,@RequestParam(defaultValue="")String q,@RequestParam(defaultValue="")String selected){ + return choices(ApiController.user(a),customer,category,field,q,selected); + } + Map choices(LegacyUser u,long customer,long category,long field,String q,String selected){ + return db.readTransaction(()->{ + access(u,customer); + var definition=one(formState(u,category).definitions().stream().filter(d->id(d,"id")==field).toList()); + return special.options(u,u.headquartersId(),text(definition,"type"),q,selected); + }); + } + @GetMapping("/prices/{category}/history") List> history(Authentication a,@PathVariable long customer,@PathVariable long category){return history(ApiController.user(a),customer,category);} + + private void access(LegacyUser u,long customer){u.require(0);u.require(2);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));} + private List> services(){return db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='service' AND mt_objtype='' AND mt_objid=0 AND mt_sort<>1 ORDER BY mt_sort,mt_id");} + Map options(LegacyUser u,long customer){access(u,customer);return fields("services",services(),"prices",db.queryForList("SELECT mtfc_id AS id,mtfc_description AS label FROM metafieldcategory WHERE mtfc_id BETWEEN 20 AND 25 ORDER BY mtfc_id"));} + private List> selection(String kind,long number){ + if(kind.equals("reasons")){if(number<0||number>3)throw bad("Es gibt vier Abliefergrundlisten (1 bis 4).");return List.of(fields("id",number,"label","Liste "+(number+1)));} + if(!kind.equals("protocol"))throw bad("Unbekannter Servicedialog."); + var all=services();if(all.stream().filter(r->id(r,"id")==number).count()!=1)throw bad("Der Service fehlt oder ist nicht eindeutig eingerichtet.");return all; + } + private List> rows(long customer,String kind){var keys=kind.equals("protocol")?PROTOCOL:List.of(REASONS);return db.queryForList("SELECT gdc_id,gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='cs' AND gdc_obj_id=? AND gdc_gen_fieldname IN ("+marks(keys.size())+") ORDER BY gdc_id",concat(customer,keys));} + private static Object[] concat(long customer,List keys){var args=new ArrayList();args.add(customer);args.addAll(keys);return args.toArray();} + private static String content(List> rows,String field){var found=rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(field)).toList();if(found.size()>1)throw bad("Mehrdeutige Servicevorgaben im Bestand: "+field);return found.isEmpty()?"":text(found.getFirst(),"gdc_content");} + // PHP uses unescaped |, =, comma and section/dollar separators. Never silently + // truncate an ambiguous payload or discard entries for other/noncontiguous services. + static LinkedHashMap split(String value){ + var result=new LinkedHashMap();if(value.isEmpty())return result; + for(String part:value.split("\\|",-1)){int at=part.indexOf('=');if(at<1||!part.substring(0,at).matches("[0-9]+")||result.putIfAbsent(part.substring(0,at),part.substring(at+1))!=null)throw bad("Die gespeicherte Servicekonfiguration ist mehrdeutig. Bitte den Bestand klären.");} + return result; + } + private static String member(List> rows,String field,long number){return split(content(rows,field)).getOrDefault(Long.toString(number),"");} + Map view(LegacyUser u,long customer,String kind,long number){access(u,customer);return db.readTransaction(()->{ + var selection=selection(kind,number);var rows=rows(customer,kind);var entries=new ArrayList(); + String value=member(rows,kind.equals("protocol")?QUESTIONS:REASONS,number); + if(!value.isEmpty()){ + String[] parts=value.split(kind.equals("protocol")?",":"§",-1); + String mail=kind.equals("protocol")?member(rows,MAIL,number):"";String[] mails=mail.split(",",-1); + for(int i=0;i save(LegacyUser u,long customer,String kind,long number,Save s){ + access(u,customer);if(s==null||s.entries()==null||s.entries().size()>21)throw bad("Maximal 21 Einträge sind möglich.");token(s.requestId());selection(kind,number); + var entries=new ArrayList();var codes=new HashSet(); + for(var e:s.entries()){ + if(e==null)throw bad("Eintrag fehlt.");String value=compatible(e.text(),kind.equals("protocol")?",":"§$",1000,"Text");if(value.isBlank())throw bad("Bitte leere Zeilen entfernen oder ausfüllen."); + if(kind.equals("protocol")){if(!Set.of("","1","2").contains(Objects.toString(e.mail(),""))||!Objects.toString(e.code(),"").isEmpty())throw bad("Ungültige Mail-Bedingung.");entries.add(new Entry("",value,Objects.toString(e.mail(),"")));} + else {String code=compatible(e.code(),"§$",50,"Code");if(!code.isEmpty()&&!codes.add(code))throw bad("Der Code ist in dieser Liste mehrfach vorhanden.");if(!Objects.toString(e.mail(),"").isEmpty())throw bad("Abliefergründe haben keine Mail-Bedingung.");entries.add(new Entry(code,value,""));} + } + String footer=compatible(s.footer(),"",65535,"Abschlusstext");if(kind.equals("reasons")&&!footer.isEmpty())throw bad("Abliefergrundlisten haben keinen Abschlusstext."); + return db.customerDocumentWriteTransaction(()->{ + 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,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()); + support.receipt(u,s.requestId(),receipt,customer);return fields("saved",true); + }); + } + private static String compatible(String value,String extra,int max,String label){String checked=Objects.toString(value,"");value=checked;if(value.getBytes(StandardCharsets.UTF_8).length>max||value.indexOf('\0')>=0)throw bad(label+": Eingabe ist zu lang oder ungültig.");if(value.contains("-,-")||("|="+extra).chars().anyMatch(c->valueContains(checked,c)))throw bad(label+": Die Zeichen "+"| = "+extra+" und die Folge -,- sind im bestehenden Speicherformat reserviert.");return value;} + private static boolean valueContains(String value,int c){return value.indexOf(c)>=0;} + private void store(long customer,List> rows,String field,long number,String value){ + var all=split(content(rows,field));String key=Long.toString(number);if(value.isEmpty())all.remove(key);else all.put(key,value); + String result=String.join("|",all.entrySet().stream().map(e->e.getKey()+"="+e.getValue()).toList());if(result.getBytes(StandardCharsets.UTF_8).length>16_777_215)throw bad("Die Servicekonfiguration ist zu groß."); + var existing=rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(field)).findFirst(); + if(existing.isPresent())db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_id=?",result,id(existing.get(),"gdc_id")); + else if(!result.isEmpty())db.insert("genericdatacontainer",fields("gdc_obj_type","cs","gdc_obj_id",customer,"gdc_gen_fieldname",field,"gdc_content",result,"gdc_context","")); + } + + private FormState formState(LegacyUser u,long category){ + if(category<20||category>25)throw bad("Unbekanntes Kundenpreisformular.");long scope=forms.fieldHeadquarters(u,category); + var categoryRow=one(db.queryForList("SELECT mtfc_id,mtfc_description,mtft_id FROM metafieldcategory WHERE mtfc_id=?",category)); + var defs=db.queryForList(MetafieldRecords.definitionsSql(category),scope);if(defs.size()>500)throw bad("Zu viele Formularfelder."); + var templateRows=db.queryForList("SELECT mtft_content FROM metafieldtemplate WHERE mtft_id=?",id(categoryRow,"mtft_id")); + String template=templateRows.isEmpty()?"":text(one(templateRows),"mtft_content");return new FormState(scope,defs,categoryRow,template); + } + private List> values(long customer,List> defs){if(defs.isEmpty())return List.of();var args=new ArrayList(List.of(customer));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());} + private Map automatic(LegacyUser u,long customer,List> definitions){ + Map owner=Map.of(); + if(definitions.stream().anyMatch(d->MasterForms.dbKey("customers",text(d,"type"))!=null)){ + owner=one(db.queryForList("SELECT cs_id,hq_id,cmp_id,cs_admin,cs_eid,cs_discount,cs_prov,cs_markup_prov FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId())); + } + return new FormAutomaticValues(db,special).values(u,"customers",owner,definitions,List.of()); + } + private static boolean snapshot(String type){return MasterForms.snapshot("customers",type);} + private static boolean automatic(String type){return snapshot(type)||type.startsWith("sys:")&&MasterForms.SYSTEM.contains(type.substring(4));} + private static boolean editable(String type){return FormFieldSupport.editable(type)||snapshot(type);} + Map form(LegacyUser u,long customer,long category){access(u,customer);return db.readTransaction(()->{ + var state=formState(u,category);var rows=values(customer,state.definitions());var vals=MetafieldRecords.values(rows); + var rules=FormTemplateRules.read(state.template(),category,state.definitions()); + var display=rules.apply(FormFieldSupport.defaults(vals,automatic(u,customer,state.definitions()))); + var inputs=new LinkedHashMap<>(vals); + for(var d:state.definitions())if(snapshot(text(d,"type"))||rules.target(id(d,"id")))inputs.put(id(d,"id"),display.getOrDefault(id(d,"id"),"")); + display=FormFieldSupport.generated(state.definitions(),display,u.userId(),false); + var schema=state.definitions().stream().map(d->{var f=new LinkedHashMap<>(special.definition(u,u.headquartersId(),d));f.put("label",Jsoup.parse(text(d,"label")).text());f.put("snapshot",snapshot(text(d,"type")));f.put("calculated",rules.target(id(d,"id")));f.put("editable",editable(text(d,"type"))&&!rules.target(id(d,"id")));return (Map)f;}).toList(); + var printDefs=schema.stream().map(d->{var f=new LinkedHashMap<>(d);if(text(d,"type").equals("sys:current_headquarters_company_logo"))f.put("type","system_image");return (Map)f;}).toList(); + String html=templates.render(fields("data",fields("values",special.display(u,schema,display,u.headquartersId())),"definitions",printDefs,"template",state.template())); + var layoutValues=new LinkedHashMap<>(special.display(u,schema,display,u.headquartersId()));var layoutDefs=printDefs.stream().map(d->{var f=new LinkedHashMap<>(d);if(Boolean.TRUE.equals(d.get("editable"))||Boolean.TRUE.equals(d.get("calculated"))){f.put("type","varchar");layoutValues.put(id(d,"id"),"NGFORMFIELD"+id(d,"id")+"END");}return (Map)f;}).toList(); + String layout=templates.render(fields("data",fields("values",layoutValues),"definitions",layoutDefs,"template",state.template())); + return fields("title",text(state.category(),"mtfc_description"),"configured",!schema.isEmpty(),"fields",schema,"values",inputs,"calculations",rules.calculations(),"templateWarning",rules.warning(),"hasUnsavedDefaults",!inputs.equals(vals),"revision",revision(rows),"schemaRevision",revision(state),"html",html,"layout",layout, + "unsupported",schema.stream().filter(d->!FormFieldSupport.editable(text(d,"type"))&&!FormFieldSupport.generated(text(d,"type"))&&!automatic(text(d,"type"))).map(d->text(d,"label")+" ("+text(d,"type")+")").toList()); + });} + Map saveForm(LegacyUser u,long customer,long category,FormSave s){ + access(u,customer);if(s==null||s.values()==null||s.values().size()>500)throw bad("Formularwerte fehlen.");token(s.requestId()); + return db.customerDocumentWriteTransaction(special.sources(formState(u,category).definitions()),()->{ + access(u,customer);var receipt=List.of("customer-price-form",customer,category,s);if(support.receipt(u,s.requestId(),receipt)!=null)return fields("saved",true); + var state=formState(u,category);unchanged(state,s.schemaRevision());var rules=FormTemplateRules.read(state.template(),category,state.definitions());rules.requireSupported();var defs=state.definitions();if(defs.isEmpty())throw bad("Für dieses Preisformular sind keine Felder eingerichtet."); + var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld."); + var rows=values(customer,defs);unchanged(rows,s.revision());var before=MetafieldRecords.values(rows); + var next=new LinkedHashMap<>(before); + for(var d:defs){ + long key=id(d,"id");if(rules.target(key))continue; + String old=before.getOrDefault(key,""),value=Objects.requireNonNullElse(s.values().getOrDefault(key,old),""); + if(old.equals(value))continue; + if(!editable(text(d,"type")))throw bad("Dieses Spezialfeld kann nicht bearbeitet werden: "+text(d,"label")); + next.put(key,validate(u,d,value,old)); + } + next=new LinkedHashMap<>(FormFieldSupport.generated(defs,rules.apply(next),u.userId(),true)); + for(var d:defs){ + long key=id(d,"id");String value=next.getOrDefault(key,""); + if(value.equals(before.getOrDefault(key,"")))continue; + db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id=?",customer,key); + db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,customer,value); + if(id(d,"history")==1)db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,customer,value,LocalDateTime.now(ZoneId.of("Europe/Berlin"))); + } + support.audit(u,0,customer,0,36,"Kundenpreisformular "+category+" geändert.",s.requestId());support.receipt(u,s.requestId(),receipt,customer);return fields("saved",true); + }); + } + private String validate(LegacyUser u,Map d,String value,String before){ + String normalized=special.validate(u,u.headquartersId(),d,value,before); + // Preserve the pre-existing free number format; PHP's float/integer types normalize. + return text(d,"type").equals("number")?value:normalized; + } + List> history(LegacyUser u,long customer,long category){access(u,customer);var defs=formState(u,category).definitions();if(defs.isEmpty())return List.of();var args=new ArrayList(List.of(customer));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC LIMIT 1000",args.toArray());} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerServiceImports.java b/backend/src/main/java/de/stadtbote/votian/CustomerServiceImports.java new file mode 100644 index 0000000..1b2128e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerServiceImports.java @@ -0,0 +1,199 @@ +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.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer CSV formats and radius rings from the current server's customer_service.php/inc_geo.inc.php. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/service-imports") +class CustomerServiceImports { + static final int LIMIT=5000; + final DatabaseService db; + final BusinessSupport support; + final OrderRoutingService routing; + CustomerServiceImports(DatabaseService db,BusinessSupport support,OrderRoutingService routing){this.db=db;this.support=support;this.routing=routing;} + record Command(String requestId,String revision,String mode,String csv,String planRevision){} + record State(List> zones,List> mappings,List> radii){} + record Source(List> addresses,List> parameters){} + record Ring(int number,double outer,double inner){} + record CsvRow(int line,List values){} + record Item(int number,long zone,long postal,String code,String country,String district,BigDecimal express,BigDecimal surcharge,BigDecimal extra){} + record Plan(String description,List rows,List steps,List> affected,Object source){} + + @GetMapping Map get(Authentication a,@PathVariable long customer){return options(ApiController.user(a),customer);} + @PostMapping("/preview") Map preview(Authentication a,@PathVariable long customer,@RequestBody Command c){return preview(ApiController.user(a),customer,c);} + @PostMapping Map save(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + void access(LegacyUser u,long customer){u.require(0);u.require(2);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));} + void validate(LegacyUser u,Command c){if(c==null||!Set.of("ZONE","ZONEAGIO","RADIUSZONE","CALCULATE").contains(c.mode()==null?"":c.mode()))throw bad("Unbekannter Importtyp.");token(c.requestId());if(!c.mode().equals("CALCULATE"))u.require(22);} + List> rows(boolean lock,String sql,Object... args){var r=lock?db.lockRows(sql,args):db.queryForList(sql,args);if(r.size()>LIMIT)throw bad("Höchstens 5000 Zuordnungen je Kunde werden unterstützt.");return r;} + State state(LegacyUser u,long customer,boolean lock){return new State( + rows(lock,"SELECT * FROM servicezone WHERE hq_id=? AND cs_id=? ORDER BY srvz_id LIMIT 5001",u.headquartersId(),customer), + rows(lock,"SELECT m.* FROM servicezonemapping m JOIN servicezone z ON z.srvz_id=m.srvz_id WHERE z.hq_id=? AND z.cs_id=? ORDER BY m.srvz_id,m.srvp_id LIMIT 5001",u.headquartersId(),customer), + rows(lock,"SELECT * FROM serviceradius WHERE hq_id=? AND cs_id=? ORDER BY srvr_id LIMIT 5001",u.headquartersId(),customer));} + Source source(LegacyUser u,long customer,boolean lock){ + var addresses=rows(lock,"SELECT a.ad_id,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country,ca.cscad_hsno AS hsno FROM customer c JOIN costcenter cc ON cc.csc_id=c.csc_id JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=4 JOIN address a ON a.ad_id=ca.ad_id WHERE c.cs_id=? AND c.hq_id=? ORDER BY a.ad_id,ca.cscad_hsno LIMIT 5001",customer,u.headquartersId()); + if(addresses.isEmpty())addresses=rows(lock,"SELECT a.ad_id,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country,co.cmp_hsno AS hsno FROM customer c JOIN company co ON co.cmp_id=c.cmp_id JOIN address a ON a.ad_id=co.ad_id WHERE c.cs_id=? AND c.hq_id=?",customer,u.headquartersId()); + return new Source(addresses,rows(lock,"SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key IN (?,?) ORDER BY par_key,par_value",u.headquartersId(),"CUSTOMER_SERVICE_RADIUS_INTERVALS_"+customer,"GEO_EARTH_RADIUS")); + } + static String parameter(Source source,String key,String fallback){var found=source.parameters().stream().filter(r->text(r,"par_key").equals(key)).toList();if(found.size()>1)throw bad("Mehrdeutige Radius-Einstellung: "+key);return found.isEmpty()||text(found.getFirst(),"par_value").isBlank()?fallback:text(found.getFirst(),"par_value");} + static String intervals(Source source,long customer){return parameter(source,"CUSTOMER_SERVICE_RADIUS_INTERVALS_"+customer,"1=10|2=20,10|3=30,20");} + Map options(LegacyUser u,long customer){access(u,customer);return db.readTransaction(()->{ + var s=state(u,customer,false);String center="",intervals="",error=""; + try{var source=source(u,customer,false);intervals=intervals(source,customer);rings(intervals);var a=address(source);center=text(a,"ad_street")+" "+text(a,"hsno")+", "+text(a,"ad_zipcode")+" "+text(a,"ad_city");geoAvailable();} + catch(org.springframework.web.server.ResponseStatusException e){error=e.getReason();} + return fields("revision",revision(s),"canImport",u.hasRight(22),"center",center,"intervals",intervals,"calculationError",error); + });} + Map preview(LegacyUser u,long customer,Command c){access(u,customer);validate(u,c);var s=db.readTransaction(()->state(u,customer,false));unchanged(s,c.revision());return preview(prepare(u,customer,s,c));} + static Map preview(Plan p){return fields("description",p.description(),"rows",p.rows(),"affected",p.affected(),"planRevision",revision(p),"count",p.rows().size());} + Map save(LegacyUser u,long customer,Command c){ + access(u,customer);validate(u,c);var receipt=List.of("customer-service-import",customer,c); + if(support.receipt(u,c.requestId(),receipt)!=null)return fields("saved",true); + var s=db.readTransaction(()->state(u,customer,false));unchanged(s,c.revision());var plan=prepare(u,customer,s,c);unchanged(plan,c.planRevision()); + support.transactional("customer","headquarters","servicezone","servicezonemapping","serviceradius","genericdatacontainer"); + if(c.mode().equals("CALCULATE"))support.transactional("costcenter","costcenteraddress","company","address","parameter"); + return db.writeTransaction(()->{ + lockHeadquarters(db,u);one(db.lockRows("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId())); + if(support.receipt(u,c.requestId(),receipt)!=null)return fields("saved",true); + unchanged(state(u,customer,true),c.revision()); + if(c.mode().equals("CALCULATE"))unchanged(source(u,customer,true),revision(plan.source())); + for(var step:plan.steps())db.update(step.sql(),step.arguments().toArray()); + support.audit(u,0,customer,0,36,plan.description(),c.requestId());support.receipt(u,c.requestId(),receipt,customer);return fields("saved",true); + }); + } + Plan prepare(LegacyUser u,long customer,State s,Command c){ + if(c.mode().equals("CALCULATE"))return calculate(u,customer,s); + var imported=new ArrayList();var errors=new ArrayList();var seen=new HashSet(); + var parsed=parse(c.csv());var codes=new TreeSet(); + for(var row:parsed){var v=row.values();int index=c.mode().equals("ZONE")?2:c.mode().equals("RADIUSZONE")?1:0;if(v.size()>index){String code=v.get(index),country=c.mode().equals("ZONE")?v.get(1).toUpperCase(Locale.ROOT):"DE";if((country.isEmpty()||country.equals("D")||country.equals("DE"))&&code.matches("[0-9]{1,5}"))code="0".repeat(5-code.length())+code;codes.add(code);}} + var postals=postals("srvp_plz",codes); + for(var row:parsed)try{ + var v=row.values();int columns=c.mode().equals("ZONE")?6:c.mode().equals("ZONEAGIO")?2:3; + if(v.size()!=columns&&(v.size()!=columns+1||!v.getLast().isEmpty()))throw bad("Erwartet werden "+columns+" Spalten (optional eine leere Abschlussspalte)."); + Item item; + if(c.mode().equals("ZONE")){ + int number=area(v.get(0));var zones=s.zones().stream().filter(z->id(z,"srvz_no")==number).toList();if(zones.size()!=1)throw bad("Zone "+number+" ist nicht eindeutig beim Kunden vorhanden."); + var p=postal(postals,v.get(2),v.get(1));long postal=id(p,"srvp_id"),zone=id(zones.getFirst(),"srvz_id"); + item=new Item(number,zone,postal,text(p,"srvp_plz"),text(p,"srvp_country"),"",price(v.get(3)),price(v.get(4)),price(v.get(5))); + if(!seen.add(""+postal))throw bad("Doppelte PLZ-Zuordnung in der Datei."); + }else if(c.mode().equals("ZONEAGIO")){ + var p=postal(postals,v.get(0),"DE");long postal=id(p,"srvp_id");var mappings=s.mappings().stream().filter(m->id(m,"srvp_id")==postal).toList();if(mappings.size()!=1)throw bad("PLZ "+text(p,"srvp_plz")+" ist dem Kunden nicht eindeutig zugeordnet."); + var m=mappings.getFirst();long zone=id(m,"srvz_id");int number=(int)id(one(s.zones().stream().filter(z->id(z,"srvz_id")==zone).toList()),"srvz_no"); + item=new Item(number,zone,postal,text(p,"srvp_plz"),"DE","",decimal(m,"srvzm_mt_1"),price(v.get(1)),decimal(m,"srvzm_mt_3")); + if(!seen.add(""+postal))throw bad("Doppelter Zonenaufschlag in der Datei."); + }else{ + var p=postal(postals,v.get(1),"DE");String district=required(v.get(2),50,"Bezirk/Stadt");if(district.chars().anyMatch(Character::isISOControl))throw bad("Bezirk/Stadt enthält Steuerzeichen."); + item=new Item(area(v.get(0)),0,id(p,"srvp_id"),text(p,"srvp_plz"),"DE",district,BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO); + if(!seen.add(item.code()+"|"+district.toLowerCase(Locale.ROOT)))throw bad("Doppelte PLZ-/Bezirkszuordnung in der Datei."); + } + imported.add(item); + }catch(org.springframework.web.server.ResponseStatusException e){if(errors.size()<20)errors.add("Zeile "+row.line()+": "+e.getReason());} + if(!errors.isEmpty())throw bad(String.join("\n",errors)); + return plan(u,customer,s,c.mode(),imported,List.of()); + } + Plan plan(LegacyUser u,long customer,State s,String mode,List imported,Object source){ + var steps=new ArrayList();var affected=new ArrayList>();var output=new ArrayList<>(imported);String description; + var numbers=new TreeSet();imported.forEach(i->numbers.add(i.number())); + if(mode.equals("ZONE")){ + var zones=new HashSet();imported.forEach(i->zones.add(i.zone())); + for(var m:s.mappings())if(zones.contains(id(m,"srvz_id")))affected.add(m);else if(imported.stream().anyMatch(i->i.postal()==id(m,"srvp_id")))throw bad("Eine importierte PLZ gehört bereits zu einer anderen, nicht ersetzten Kundenzone."); + for(long zone:new TreeSet<>(zones))add(steps,"DELETE FROM servicezonemapping WHERE srvz_id=?",zone); + for(var i:imported)add(steps,"INSERT INTO servicezonemapping (srvz_id,srvp_id,srvzm_mt_1,srvzm_mt_2,srvzm_mt_3) VALUES (?,?,?,?,?)",i.zone(),i.postal(),i.express(),i.surcharge(),i.extra()); + if(s.mappings().size()-affected.size()+imported.size()>LIMIT)throw bad("Der Import würde mehr als 5000 PLZ-Zuordnungen erzeugen."); + description="Zonen "+numbers+": "+affected.size()+" bisherige PLZ-Zuordnungen einschließlich ihrer drei Zuschläge durch "+imported.size()+" Importzeilen ersetzen. Zonenbezeichnungen und die vier Zonenpreise bleiben erhalten."; + }else if(mode.equals("ZONEAGIO")){ + var byPostal=new HashMap();imported.forEach(i->byPostal.put(i.postal(),i));output.clear(); + var postals=new HashMap>();for(var p:db.queryForList("SELECT p.srvp_id,p.srvp_plz,p.srvp_country FROM serviceplz p JOIN servicezonemapping m ON m.srvp_id=p.srvp_id JOIN servicezone z ON z.srvz_id=m.srvz_id WHERE z.cs_id=? AND z.hq_id=?",customer,u.headquartersId()))postals.put(id(p,"srvp_id"),p); + for(var m:s.mappings()){ + long zone=id(m,"srvz_id"),postal=id(m,"srvp_id");var p=postals.get(postal);if(p==null)throw bad("Eine bestehende PLZ-Zuordnung verweist auf einen fehlenden Basisdatensatz."); + var replacement=byPostal.get(postal);BigDecimal price=replacement==null?BigDecimal.ZERO:replacement.surcharge(); + int number=(int)id(one(s.zones().stream().filter(z->id(z,"srvz_id")==zone).toList()),"srvz_no"); + output.add(new Item(number,zone,postal,text(p,"srvp_plz"),text(p,"srvp_country"),"",decimal(m,"srvzm_mt_1"),price,decimal(m,"srvzm_mt_3"))); + if(decimal(m,"srvzm_mt_2").compareTo(price)!=0)affected.add(m); + } + for(var z:s.zones())add(steps,"UPDATE servicezonemapping SET srvzm_mt_2=0 WHERE srvz_id=?",id(z,"srvz_id")); + for(var i:imported)add(steps,"UPDATE servicezonemapping SET srvzm_mt_2=? WHERE srvz_id=? AND srvp_id=?",i.surcharge(),i.zone(),i.postal()); + description="Zonenaufschläge aller "+s.mappings().size()+" PLZ-Zuordnungen zunächst auf 0 setzen und "+imported.size()+" CSV-Werte übernehmen ("+affected.size()+" Änderungen). Express, Zusatzwert 3 und Zonenpreise bleiben erhalten."; + }else{ + boolean all=mode.equals("CALCULATE"); + for(var r:s.radii())if(all||numbers.contains((int)id(r,"srvr_radiusarea_no")))affected.add(r);else if(imported.stream().anyMatch(i->i.code().equals(text(r,"srvr_zipcode"))&&i.district().equalsIgnoreCase(text(r,"srvr_city_district"))))throw bad("Eine PLZ-/Bezirkszuordnung gehört bereits zu einem anderen, nicht ersetzten Radiusbereich."); + if(all)add(steps,"DELETE FROM serviceradius WHERE hq_id=? AND cs_id=?",u.headquartersId(),customer); + else for(int number:numbers)add(steps,"DELETE FROM serviceradius WHERE hq_id=? AND cs_id=? AND srvr_radiusarea_no=?",u.headquartersId(),customer,number); + for(var i:imported)add(steps,"INSERT INTO serviceradius (hq_id,cs_id,srvr_zipcode,srvr_city_district,srvr_radiusarea_no,srvr_price_1) VALUES (?,?,?,?,?,0)",u.headquartersId(),customer,i.code(),i.district(),i.number()); + if(s.radii().size()-affected.size()+imported.size()>LIMIT)throw bad("Der Import würde mehr als 5000 Radiuszuordnungen erzeugen."); + long prices=affected.stream().filter(r->decimal(r,"srvr_price_1").signum()!=0).count(); + description=(all?"Alle Radiusbereiche neu berechnen":"Radiusbereiche "+numbers+" importieren")+": "+affected.size()+" bisherige Zuordnungen durch "+imported.size()+" Zuordnungen ersetzen. "+prices+" vorhandene Preiswerte werden entfernt; neue Radiuspreise sind 0."; + } + if(mode.equals("ZONE")||mode.equals("ZONEAGIO")){ + var previousPostals=postals("srvp_id",new TreeSet<>(affected.stream().map(r->id(r,"srvp_id")).toList())); + var indexed=new HashMap>();previousPostals.forEach(p->indexed.put(id(p,"srvp_id"),p)); + for(int k=0;k(old);var p=indexed.get(id(old,"srvp_id"));if(p==null)throw bad("Eine bisherige PLZ-Zuordnung hat keinen Basisdatensatz."); + value.put("code",text(p,"srvp_plz"));value.put("country",text(p,"srvp_country"));value.put("number",id(one(s.zones().stream().filter(z->id(z,"srvz_id")==id(old,"srvz_id")).toList()),"srvz_no"));affected.set(k,value); + } + } + return new Plan(description,List.copyOf(output),List.copyOf(steps),List.copyOf(affected),source); + } + void geoAvailable(){ + if(db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='phoenix_special' AND TABLE_NAME='cityradius'",Integer.class)!=1||db.queryForList("SELECT cr_zipcode FROM phoenix_special.cityradius LIMIT 1").isEmpty())throw bad("Der Orts-/Bezirksbestand für die Radiusberechnung (phoenix_special.cityradius) fehlt oder ist leer. Vorhandene Zuordnungen bleiben erhalten."); + } + static Map address(Source s){if(s.addresses().size()!=1)throw bad("Die Abhol-/Lieferadresse des Kunden ist nicht eindeutig vorhanden.");var a=s.addresses().getFirst();if(!Set.of("D","DE").contains(text(a,"ad_country")))throw bad("Die Radiusberechnung benötigt eine deutsche Kundenadresse.");for(String f:List.of("ad_street","ad_zipcode","ad_city"))required(text(a,f),50,"Kundenadresse");return a;} + Plan calculate(LegacyUser u,long customer,State s){ + geoAvailable();var source=db.readTransaction(()->source(u,customer,false));var address=address(source);var rings=rings(intervals(source,customer));double earth; + try{earth=Double.parseDouble(parameter(source,"GEO_EARTH_RADIUS","6371.0"));if(!Double.isFinite(earth)||earth<=0)throw new NumberFormatException();}catch(NumberFormatException e){throw bad("Ungültiger Erdradius in GEO_EARTH_RADIUS.");} + var center=routing.geocode(new OrderEntryModels.Station(null,Models.StopType.PICKUP,"Kundenadresse",text(address,"ad_street"),text(address,"hsno"),text(address,"ad_zipcode"),text(address,"ad_city"),"DE","","","","",null,null,List.of())); + var imported=new ArrayList();var unique=new HashSet(); + for(var ring:rings){ + var rows=db.queryForList("SELECT cr_zipcode,cr_city,cr_district,lat,lng FROM (SELECT cr_zipcode,cr_city,cr_district,lat,lng,ACOS(LEAST(1,GREATEST(-1,SIN(RADIANS(?))*SIN(RADIANS(lat))+COS(RADIANS(?))*COS(RADIANS(lat))*COS(RADIANS(lng)-RADIANS(?)))))*? AS distance FROM phoenix_special.cityradius WHERE lat BETWEEN -90 AND 90 AND lng BETWEEN -180 AND 180) distances WHERE distance < ? AND distance >= ? ORDER BY cr_city,cr_zipcode,cr_district,lat,lng LIMIT 10001",center.latitude(),center.latitude(),center.longitude(),earth,ring.outer(),ring.inner()); + if(rows.size()>10000)throw bad("Zu viele Orts-/Bezirkstreffer. Bitte die Radiusintervalle eingrenzen."); + for(int k=0;k50)throw bad("Ungültiger PLZ-/Bezirksdatensatz im Radiusbestand."); + String key=code+"|"+district.toLowerCase(Locale.ROOT);if(!unique.add(key))throw bad("Mehrdeutiger Radiusbestand: "+code+" · "+district+" liegt in mehreren Bereichen."); + imported.add(new Item(ring.number(),0,0,code,"DE",district,BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO));if(imported.size()>LIMIT)throw bad("Mehr als 5000 Radiuszuordnungen. Bitte die Radiusintervalle eingrenzen."); + } + } + if(imported.isEmpty())throw bad("Keine Orte/Bezirke innerhalb der eingestellten Radien gefunden. Vorhandene Zuordnungen bleiben erhalten."); + return plan(u,customer,s,"CALCULATE",imported,source); + } + static List rings(String input){ + var sorted=new TreeMap();try{ + for(String part:input.split("\\|",-1)){String[] pair=part.split("=",-1),bounds=pair.length==2?pair[1].split(",",-1):new String[0];if(bounds.length<1||bounds.length>2)throw new IllegalArgumentException();int key=Integer.parseInt(pair[0].trim());double outer=Double.parseDouble(bounds[0].trim()),inner=bounds.length==1?0:Double.parseDouble(bounds[1].trim());if(key<0||!Double.isFinite(outer)||!Double.isFinite(inner)||inner<0||outer<=inner||sorted.put(key,new double[]{outer,inner})!=null)throw new IllegalArgumentException();} + if(sorted.size()>127)throw new IllegalArgumentException();var result=new ArrayList();for(var b:sorted.values())result.add(new Ring(result.size()+1,b[0],b[1])); + for(var a:result)for(var b:result)if(a.number()!=b.number()&&Math.max(a.inner(),b.inner())> postals(String column,SortedSet values){ + var args=new ArrayList<>(values);var result=new ArrayList>(); + for(int i=0;i postal(List> postals,String code,String country){country=country.strip().toUpperCase(Locale.ROOT);if(country.isEmpty()||country.equals("D"))country="DE";if(!country.matches("[A-Z]{2}"))throw bad("Land muss ein zweistelliges Länderkürzel sein.");code=required(code,10,"PLZ");if(country.equals("DE")&&code.matches("[0-9]{1,5}"))code="0".repeat(5-code.length())+code; + String normalizedCode=code,normalizedCountry=country;var r=postals.stream().filter(p->text(p,"srvp_plz").equals(normalizedCode)&&text(p,"srvp_country").equalsIgnoreCase(normalizedCountry)).toList();if(r.size()!=1)throw bad("PLZ "+code+" ("+country+") ist im Basisbestand nicht eindeutig vorhanden.");return r.getFirst();} + static int area(String value){try{if(!value.matches("[0-9]{1,3}"))throw new NumberFormatException();int n=Integer.parseInt(value);if(n>127)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad("Bereichs-/Zonennummer muss zwischen 0 und 127 liegen.");}} + static BigDecimal price(String value){if(value.isBlank())return BigDecimal.ZERO;if(!value.matches("-?[0-9]{1,5}([.,][0-9]{1,2})?"))throw bad("Ungültiger Preis: maximal 5 Vorkommastellen und 2 Nachkommastellen, ohne Tausendertrenner.");return new BigDecimal(value.replace(',','.'));} + static BigDecimal decimal(Map r,String field){return new BigDecimal(r.get(field).toString());} + static void add(List steps,String sql,Object... args){steps.add(new CustomerServiceSettings.Step(sql,List.of(args)));} + static List parse(String input){ + if(input==null||input.isBlank()||input.length()>1_000_000)throw bad("Bitte eine nicht leere CSV-Datei mit höchstens 1 MB auswählen."); + if(input.startsWith("\ufeff"))input=input.substring(1);input=input.replace("\r\n","\n").replace('\r','\n'); + var rows=new ArrayList();var fields=new ArrayList();var value=new StringBuilder();boolean quoted=false,closed=false;int line=1,start=1; + for(int k=0;k<=input.length();k++){ + char ch=k==input.length()?'\n':input.charAt(k); + if(quoted){if(ch=='"'){if(k+1!s.isEmpty()))rows.add(new CsvRow(start,List.copyOf(fields)));fields.clear();line++;start=line;if(rows.size()>LIMIT)throw bad("Höchstens 5000 CSV-Zeilen werden unterstützt.");} + }else{if(ch=='"'||closed&&!Character.isWhitespace(ch)||ch=='\u0000')throw bad("Ungültiges CSV-Format in Zeile "+line+".");value.append(ch);} + } + if(rows.isEmpty())throw bad("Die CSV-Datei enthält keine Datenzeilen.");return rows; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerServiceSettings.java b/backend/src/main/java/de/stadtbote/votian/CustomerServiceSettings.java new file mode 100644 index 0000000..e4cab8d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerServiceSettings.java @@ -0,0 +1,170 @@ +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.LocalTime; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer-specific zones and metatypes from the current customer_service.php. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/service-settings") +class CustomerServiceSettings { + private static final List TYPES=List.of("day_time","delivery_time","discount_mode","sms_time","cartage_time"); + final DatabaseService db; + final BusinessSupport support; + CustomerServiceSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + + record Command(String requestId,String revision,String action,long id,long zoneId,String postalCode, + String country,String district,Integer number,String name,String type,String from,String to,String mapped) {} + record Step(String sql,List arguments) {} + record Change(String description,List steps,int operation) {} + record State(List> zones,List> mappings,List> radii, + List> entries,List> defaults) {} + + @GetMapping Map get(Authentication a,@PathVariable long customer){return view(ApiController.user(a),customer);} + @PostMapping("/preview") Map preview(Authentication a,@PathVariable long customer,@RequestBody Command c){return preview(ApiController.user(a),customer,c);} + @PostMapping Map post(Authentication a,@PathVariable long customer,@RequestBody Command c){return save(ApiController.user(a),customer,c);} + + private void access(LegacyUser u,long customer){u.require(0);u.require(2);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));} + private boolean root(LegacyUser u){return u.employeeId()>0&&id(one(db.queryForList("SELECT hq_admin FROM headquarters WHERE hq_id=?",u.headquartersId())),"hq_admin")==u.employeeId();} + private List> rows(boolean lock,String sql,Object... args){var rows=lock?db.lockRows(sql,args):db.queryForList(sql,args);if(rows.size()>5000)throw bad("Mehr als 5000 Serviceeinträge. Bitte den Bestand eingrenzen lassen.");return rows;} + private State state(LegacyUser u,long customer,boolean lock){ + var zones=rows(lock,"SELECT * FROM servicezone WHERE hq_id=? AND cs_id=? ORDER BY srvz_no,srvz_id LIMIT 5001",u.headquartersId(),customer); + var mappings=rows(lock,"SELECT m.* FROM servicezonemapping m JOIN servicezone z ON z.srvz_id=m.srvz_id WHERE z.hq_id=? AND z.cs_id=? ORDER BY m.srvz_id,m.srvp_id LIMIT 5001",u.headquartersId(),customer); + var radii=rows(lock,"SELECT * FROM serviceradius WHERE hq_id=? AND cs_id=? ORDER BY srvr_zipcode,srvr_city_district,srvr_id LIMIT 5001",u.headquartersId(),customer); + var entries=rows(lock,"SELECT * FROM metatype WHERE mt_objtype='cs' AND mt_objid=? AND mt_type IN ('day_time','delivery_time','discount_mode','sms_time','cartage_time') ORDER BY mt_type,mt_sort,mt_id LIMIT 5001",customer); + var defaults=rows(lock,"SELECT * FROM metatype WHERE mt_objtype='' AND mt_objid=0 AND mt_type IN ('day_time','delivery_time','discount_mode','sms_time','cartage_time') ORDER BY mt_type,mt_sort,mt_id LIMIT 5001"); + return new State(zones,mappings,radii,entries,defaults); + } + Map view(LegacyUser u,long customer){access(u,customer);return db.readTransaction(()->{ + var s=state(u,customer,false);boolean root=root(u); + var mappings=db.queryForList("SELECT m.srvz_id AS zoneId,m.srvp_id AS postalId,p.srvp_plz AS postalCode,p.srvp_country AS country,m.srvzm_mt_1 AS express,m.srvzm_mt_2 AS surcharge,m.srvzm_mt_3 AS extra FROM servicezonemapping m JOIN servicezone z ON z.srvz_id=m.srvz_id LEFT JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE z.hq_id=? AND z.cs_id=? ORDER BY p.srvp_plz,m.srvz_id",u.headquartersId(),customer); + return fields("revision",revision(s),"canManageZones",root||u.hasRight(29),"canDeleteZones",root,"canManageDayTimes",root||u.hasRight(29),"canManageDiscounts",root, + "zones",s.zones().stream().map(z->fields("id",id(z,"srvz_id"),"number",id(z,"srvz_no"),"name",text(z,"srvz_name"),"prices",List.of(z.get("srvz_price"),z.get("srvz_price_1"),z.get("srvz_price_2"),z.get("srvz_price_3")))).toList(), + "mappings",mappings,"radii",s.radii().stream().map(r->fields("id",id(r,"srvr_id"),"postalCode",text(r,"srvr_zipcode"),"district",text(r,"srvr_city_district"),"number",id(r,"srvr_radiusarea_no"),"price",r.get("srvr_price_1"))).toList(), + "entries",s.entries().stream().map(CustomerServiceSettings::entry).toList(),"defaults",s.defaults().stream().map(CustomerServiceSettings::entry).toList()); + });} + private static Map entry(Map r){return fields("id",id(r,"mt_id"),"type",text(r,"mt_type"),"sort",id(r,"mt_sort"),"name",text(r,"mt_value"),"mapped",text(r,"mt_mapped_value"));} + Map preview(LegacyUser u,long customer,Command c){access(u,customer);return db.readTransaction(()->{var s=state(u,customer,false);var change=prepare(u,customer,s,c);return fields("description",change.description(),"statements",change.steps().size());});} + Map save(LegacyUser u,long customer,Command c){ + access(u,customer);if(c==null)throw bad("Änderung fehlt.");token(c.requestId());support.transactional("customer","headquarters","servicezone","servicezonemapping","serviceradius","metatype","genericdatacontainer"); + return db.writeTransaction(()->{ + lockHeadquarters(db,u);one(db.lockRows("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId())); + var receipt=List.of("customer-service-settings",customer,c); + if(support.receipt(u,c.requestId(),receipt)!=null)return fields("saved",true); + var s=state(u,customer,true);var change=prepare(u,customer,s,c); + for(var step:change.steps())db.update(step.sql(),step.arguments().toArray()); + support.audit(u,0,customer,0,change.operation(),change.description(),c.requestId());support.receipt(u,c.requestId(),receipt,customer); + return fields("saved",true); + }); + } + private Change prepare(LegacyUser u,long customer,State s,Command c){ + if(c==null||c.action()==null)throw bad("Änderung fehlt.");token(c.requestId());unchanged(s,c.revision()); + var steps=new ArrayList();String description;int operation=36; + switch(c.action()){ + case "ZONE_ADD" -> { + zoneRight(u);String name=required(c.name(),50,"Zonenbezeichnung"); + if(s.zones().stream().anyMatch(z->text(z,"srvz_name").equalsIgnoreCase(name)))throw bad("Diese Zonenbezeichnung existiert bereits."); + long number=s.zones().stream().mapToLong(z->id(z,"srvz_no")).max().orElse(-1)+1;if(number>127)throw bad("Keine weitere Zonennummer verfügbar."); + add(steps,"INSERT INTO servicezone (hq_id,cs_id,srvz_no,srvz_name) VALUES (?,?,?,?)",u.headquartersId(),customer,number,name); + description="Servicezone "+number+" · "+name+" anlegen."; + } + case "ZONE_RENAME" -> { + zoneRight(u);var z=find(s.zones(),"srvz_id",c.id());String name=required(c.name(),50,"Zonenbezeichnung"); + if(s.zones().stream().anyMatch(v->id(v,"srvz_id")!=c.id()&&text(v,"srvz_name").equalsIgnoreCase(name)))throw bad("Diese Zonenbezeichnung existiert bereits."); + add(steps,"UPDATE servicezone SET srvz_name=? WHERE srvz_id=? AND cs_id=? AND hq_id=?",name,c.id(),customer,u.headquartersId()); + description="Servicezone "+id(z,"srvz_no")+": „"+text(z,"srvz_name")+"“ in „"+name+"“ umbenennen. Preise bleiben erhalten."; + } + case "ZONE_DELETE" -> { + rootRight(u);var z=find(s.zones(),"srvz_id",c.id());long count=s.mappings().stream().filter(m->id(m,"srvz_id")==c.id()).count(); + add(steps,"DELETE FROM servicezonemapping WHERE srvz_id=?",c.id());add(steps,"DELETE FROM servicezone WHERE srvz_id=? AND cs_id=? AND hq_id=?",c.id(),customer,u.headquartersId()); + description="Servicezone "+id(z,"srvz_no")+" · "+text(z,"srvz_name")+" mit ihren vier Preiswerten und "+count+" PLZ-Zuordnungen löschen.";operation=135; + } + case "MAPPING_ADD" -> { + var z=find(s.zones(),"srvz_id",c.zoneId());var p=postal(c.postalCode(),c.country());long postal=id(p,"srvp_id"); + if(s.mappings().stream().anyMatch(m->id(m,"srvp_id")==postal))throw bad("Diese PLZ ist dem Kunden bereits zugeordnet. Zuerst die bestehende Zuordnung entfernen."); + add(steps,"INSERT INTO servicezonemapping (srvz_id,srvp_id) VALUES (?,?)",c.zoneId(),postal); + description="PLZ "+text(p,"srvp_plz")+" ("+text(p,"srvp_country")+") der Zone "+id(z,"srvz_no")+" · "+text(z,"srvz_name")+" zuordnen. Zuschläge beginnen bei 0."; + } + case "MAPPING_REMOVE" -> { + find(s.zones(),"srvz_id",c.zoneId());var found=s.mappings().stream().filter(m->id(m,"srvz_id")==c.zoneId()&&id(m,"srvp_id")==c.id()).toList();one(found); + add(steps,"DELETE FROM servicezonemapping WHERE srvz_id=? AND srvp_id=?",c.zoneId(),c.id()); + description="PLZ-Zuordnung #"+c.id()+" aus Servicezone #"+c.zoneId()+" einschließlich Express-/Zuschlagswerten entfernen."; + } + case "RADIUS_ADD","RADIUS_UPDATE" -> { + if(c.action().equals("RADIUS_UPDATE"))find(s.radii(),"srvr_id",c.id()); + var p=postal(c.postalCode(),"DE");String code=text(p,"srvp_plz"),district=required(c.district(),50,"Bezirk/Stadt"); + if(c.number()==null||c.number()<0||c.number()>127)throw bad("Radiusbereich muss zwischen 0 und 127 liegen."); + if(s.radii().stream().anyMatch(r->id(r,"srvr_id")!=(c.action().equals("RADIUS_UPDATE")?c.id():0)&&text(r,"srvr_zipcode").equals(code)&&text(r,"srvr_city_district").equalsIgnoreCase(district)))throw bad("Diese PLZ und dieser Bezirk sind bereits zugeordnet."); + if(c.action().equals("RADIUS_ADD"))add(steps,"INSERT INTO serviceradius (hq_id,cs_id,srvr_zipcode,srvr_city_district,srvr_radiusarea_no) VALUES (?,?,?,?,?)",u.headquartersId(),customer,code,district,c.number()); + else add(steps,"UPDATE serviceradius SET srvr_zipcode=?,srvr_city_district=?,srvr_radiusarea_no=? WHERE srvr_id=? AND cs_id=? AND hq_id=?",code,district,c.number(),c.id(),customer,u.headquartersId()); + description="Radiuszuordnung "+code+" · "+district+" auf Bereich "+c.number()+" setzen. Bestehender Preis bleibt erhalten."; + } + case "RADIUS_REMOVE" -> { + var r=find(s.radii(),"srvr_id",c.id());add(steps,"DELETE FROM serviceradius WHERE srvr_id=? AND cs_id=? AND hq_id=?",c.id(),customer,u.headquartersId()); + description="Radiuszuordnung "+text(r,"srvr_zipcode")+" · "+text(r,"srvr_city_district")+" einschließlich Preis entfernen."; + } + case "META_ADD","META_UPDATE","META_REMOVE_LAST","META_DEFAULTS","TIME_SET" -> { + String type=c.type();if(type==null||!TYPES.contains(type))throw bad("Unbekannte Servicevorgabe.");if(type.equals("day_time"))zoneRight(u);if(type.equals("discount_mode"))rootRight(u); + var current=s.entries().stream().filter(e->text(e,"mt_type").equals(type)).toList(); + if(current.stream().map(e->id(e,"mt_sort")).distinct().count()!=current.size())throw bad("Doppelte Nummern in den bestehenden Servicevorgaben. Bitte den Bestand klären."); + boolean clock=type.equals("sms_time")||type.equals("cartage_time"); + if(c.action().equals("META_DEFAULTS")){ + var defaults=s.defaults().stream().filter(e->text(e,"mt_type").equals(type)).toList();if(defaults.isEmpty())throw bad("Für diesen Bereich sind keine Standardwerte hinterlegt."); + if(type.equals("delivery_time"))for(var e:current)if(defaults.stream().noneMatch(d->id(d,"mt_sort")==id(e,"mt_sort")))checkDeliveryRemoval(s,id(e,"mt_sort")); + if(defaults.stream().map(e->id(e,"mt_sort")).distinct().count()!=defaults.size()||defaults.stream().anyMatch(e->id(e,"mt_sort")<0||(type.equals("delivery_time")&&id(e,"mt_sort")>3))||clock&&(defaults.size()!=1||id(defaults.getFirst(),"mt_sort")!=0))throw bad("Die Standardwerte sind nicht eindeutig oder passen nicht zu diesem Bereich."); + add(steps,"DELETE FROM metatype WHERE mt_type=? AND mt_objtype='cs' AND mt_objid=?",type,customer); + for(var e:defaults)insertMeta(steps,customer,type,id(e,"mt_sort"),text(e,"mt_value"),text(e,"mt_mapped_value")); + description=label(type)+": "+current.size()+" Kundenwerte durch "+defaults.size()+" Standardwerte ersetzen: "+defaults.stream().map(e->id(e,"mt_sort")+" · "+text(e,"mt_value")+" "+text(e,"mt_mapped_value")).toList(); + }else if(c.action().equals("META_REMOVE_LAST")){ + if(clock||current.isEmpty())throw bad("Kein letzter Listeneintrag vorhanden.");var last=current.getLast();if(id(last,"mt_id")!=c.id())throw bad("Es darf nur der letzte Eintrag entfernt werden, damit bestehende Nummern erhalten bleiben."); + if(type.equals("delivery_time"))checkDeliveryRemoval(s,id(last,"mt_sort")); + add(steps,"DELETE FROM metatype WHERE mt_id=? AND mt_type=? AND mt_objtype='cs' AND mt_objid=?",c.id(),type,customer); + description=label(type)+": letzten Eintrag "+id(last,"mt_sort")+" · "+text(last,"mt_value")+" entfernen. Frühere Nummern bleiben erhalten."; + }else{ + if(clock!=c.action().equals("TIME_SET"))throw bad("Ungültige Aktion für diese Servicevorgabe."); + String name=clock?"":required(c.name(),100,"Bezeichnung"),mapped=mapped(s,c); + long sort=current.stream().mapToLong(e->id(e,"mt_sort")).max().orElse(-1)+1; + if(c.action().equals("META_UPDATE")){ + var entry=find(current,"mt_id",c.id());sort=id(entry,"mt_sort"); + add(steps,"UPDATE metatype SET mt_value=?,mt_mapped_value=? WHERE mt_id=? AND mt_objtype='cs' AND mt_objid=?",name,mapped,c.id(),customer); + }else if(clock){ + sort=0;add(steps,"DELETE FROM metatype WHERE mt_type=? AND mt_objtype='cs' AND mt_objid=?",type,customer);insertMeta(steps,customer,type,0,name,mapped); + }else{ + if(sort>32767||type.equals("delivery_time")&&sort>3)throw bad("Höchstens vier Auslieferarten mit festen Nummern 0 bis 3 sind möglich."); + insertMeta(steps,customer,type,sort,name,mapped); + } + description=label(type)+": Eintrag "+sort+" · "+name+" auf „"+mapped+"“ setzen. Bestehende Nummern werden nicht verschoben."; + } + } + default -> throw bad("Unbekannte Serviceaktion."); + } + return new Change(description,List.copyOf(steps),operation); + } + private static void checkDeliveryRemoval(State s,long number){ + if(number<0||number>3)throw bad("Ungültige Auslieferart im Bestand.");String price=number==0?"srvz_price":"srvz_price_"+number; + if(s.zones().stream().anyMatch(z->new java.math.BigDecimal(z.get(price).toString()).signum()!=0)||(number>0&&s.mappings().stream().anyMatch(m->new java.math.BigDecimal(m.get("srvzm_mt_"+number).toString()).signum()!=0)))throw bad("Diese Auslieferart hat noch Zonenpreise oder PLZ-Zuschläge und kann nicht entfernt werden."); + } + private static String mapped(State s,Command c){ + return switch(c.type()){ + case "day_time" -> {String from=time(c.from()),to=time(c.to());if(from.compareTo(to)>0)throw bad("Der Tageszeitabschnitt darf nicht über Mitternacht gehen.");yield from+" - "+to;} + case "delivery_time" -> time(c.to()); + case "sms_time","cartage_time" -> time(c.from()); + case "discount_mode" -> {String value=required(c.mapped(),5,"Rabatt-Typ");if(s.defaults().stream().noneMatch(e->text(e,"mt_type").equals("discount_mode")&&Long.toString(id(e,"mt_sort")).equals(value)))throw bad("Unbekannter Rabatt-Typ.");yield value;} + default -> throw bad("Unbekannte Zeitvorgabe."); + }; + } + static String time(String value){if(value==null||!value.matches("[0-2][0-9]:[0-5][0-9]"))throw bad("Bitte eine Uhrzeit im Format HH:mm eingeben.");try{LocalTime.parse(value);return value;}catch(Exception e){throw bad("Ungültige Uhrzeit.");}} + private Map postal(String code,String country){code=required(code,10,"PLZ");country=required(country,10,"Land").toUpperCase(Locale.ROOT);var rows=db.queryForList("SELECT srvp_id,srvp_plz,srvp_country FROM serviceplz WHERE srvp_plz=? AND srvp_country=?",code,country);if(rows.size()!=1)throw bad("Diese PLZ ist für das Land nicht eindeutig im Basisbestand vorhanden.");return rows.getFirst();} + private void zoneRight(LegacyUser u){if(!root(u))u.require(29);} + private void rootRight(LegacyUser u){if(!root(u))throw new org.springframework.security.access.AccessDeniedException("Diese Änderung ist dem Administrator der Niederlassung vorbehalten.");} + private static Map find(List> rows,String key,long target){return one(rows.stream().filter(r->id(r,key)==target).toList());} + private static void add(List steps,String sql,Object... args){steps.add(new Step(sql,List.of(args)));} + private static void insertMeta(List steps,long customer,String type,long sort,String name,String mapped){add(steps,"INSERT INTO metatype (mt_type,mt_objtype,mt_objid,mt_sort,mt_value,mt_mapped_value) VALUES (?,'cs',?,?,?,?)",type,customer,sort,name,mapped);} + static String label(String type){return switch(type){case "day_time"->"Tageszeiten";case "delivery_time"->"Auslieferarten";case "discount_mode"->"Rabattaktionen";case "sms_time"->"SMS-Versandzeit";case "cartage_time"->"Rollkarten-Versandzeit";default->type;};} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerSimilarity.java b/backend/src/main/java/de/stadtbote/votian/CustomerSimilarity.java new file mode 100644 index 0000000..71976d6 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerSimilarity.java @@ -0,0 +1,52 @@ +package de.stadtbote.votian; + +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.*; + +/** nearBySearch.php matching and weighting, without its exponential UNION expansion. */ +@RestController +class CustomerSimilarity { + final DatabaseService db; + CustomerSimilarity(DatabaseService db){this.db=db;} + record Rule(boolean like,String pattern,int weight){} + record Plan(String sql,List args){} + record Hit(long id,String number,String company,String addition,long headquartersId,String headquarters,int score,boolean archived,boolean hidden,boolean canOpen){} + record Result(String phrase,List words,List items,int offset,boolean hasMore,List headquarters){} + @GetMapping("/api/master-data/customers/similar") Result get(Authentication a,@RequestParam String phrase,@RequestParam(defaultValue="0") int offset){return search(ApiController.user(a),phrase,offset);} + static List words(String input){ + if(input==null||input.length()>50||input.chars().anyMatch(Character::isISOControl))throw bad("Bitte einen Firmennamen mit höchstens 50 Zeichen eingeben."); + String value=input.strip();for(String remove:List.of(" mit "," und "," oder "))value=value.replace(remove," ");for(String remove:List.of(" GmbH"," Co."," KG"))value=value.replace(remove,""); + String punctuation="!§%-_.:,;$?+*|=()[]<>#'&";var clean=new StringBuilder();for(int ch:value.codePoints().toArray())if(punctuation.indexOf(ch)<0)clean.appendCodePoint(ch); + var words=Arrays.stream(clean.toString().split(" +")).filter(s->!s.isBlank()).limit(3).toList(); + if(words.isEmpty())throw bad("Bitte mindestens einen suchbaren Namensbestandteil eingeben.");return words; + } + static String regexLiteral(String value){var out=new StringBuilder();for(int ch:value.codePoints().toArray()){if("\\.^$|?*+()[]{}".indexOf(ch)>=0)out.append('\\');out.appendCodePoint(ch);}return out.toString();} + static List rules(String word){ + var result=new ArrayList();result.add(new Rule(true,EntryCostCenterSearch.term(word,false),10));var letters=word.codePoints().mapToObj(c->new String(Character.toChars(c))).toList();result.add(new Rule(false,String.join("",letters.stream().map(c->regexLiteral(c)+"+").toList()),9)); + for(int j=0;j words,List headquarters,int offset){ + var args=new ArrayList();var columns=new ArrayList(); + for(int i=0;i();for(int i=0;i();scores.add("CASE WHEN "+String.join(" AND ",conditions)+" THEN "+all+" ELSE -1000 END"); + for(int i=0;i-1000 ORDER BY score DESC,cmp_comp,cs_id LIMIT 101 OFFSET ?",List.copyOf(args)); + } + Result search(LegacyUser u,String phrase,int offset){u.require(0);if(offset<0||offset>1000000||offset%100!=0)throw bad("Ungültige Ergebnisseite.");var words=words(phrase);return db.readTransaction(()->{ + var access=MasterDirectoryScope.access(db,u,MasterListColumns.Kind.customers);boolean onlyHere=new OrderEntryCatalog(db).parameter(0,"SIMILARITY_SEARCH_FILTER_HQ").equals("1"); + var branches=access.branches().stream().filter(h->!onlyHere||id(h,"id")==u.headquartersId()).toList();var hqs=branches.stream().map(h->id(h,"id")).toList();var p=plan(words,hqs,offset);var rows=db.queryForList(p.sql(),p.args().toArray()); + var items=rows.stream().limit(100).map(r->new Hit(id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"),text(r,"cmp_comp2"),id(r,"hq_id"),text(r,"hq_mnemonic"),(int)id(r,"score"),id(r,"cmp_archived")!=0,id(r,"cmp_visible")==0,id(r,"hq_id")==u.headquartersId())).toList(); + return new Result(phrase.strip(),words,items,offset,rows.size()>100,branches.stream().map(h->text(h,"label")).toList()); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java b/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java new file mode 100644 index 0000000..b53b9e7 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerStammblatt.java @@ -0,0 +1,68 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.http.*; +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 replacement for pdf_generic.php generatePDFCustomer. */ +@RestController +@RequestMapping("/api/master/customers/{customer}/stammblatt") +class CustomerStammblatt { + record Item(String label,String value){} + record Section(String title,List items){} + record State(long customer,String label,List
sections,String revision){} + record Preview(int page,String revision){} + 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));} + 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."); + return db.readTransaction(()->{ + var u=access(actor);var row=masters.load(u,"customers",customer,false); + var account=one(db.queryForList("SELECT usr_account FROM user WHERE usr_id=? AND hq_id=? AND usr_type=2",id(row,"usr_id"),u.headquartersId())); + var tax=one(db.queryForList("SELECT tx_value,tx_sign FROM tax WHERE tx_id=?",id(row,"tx_id"))); + var branches=db.queryForList("SELECT br_key,br_name FROM branch WHERE br_id=?",id(row,"br_id"));String branch=branches.isEmpty()?"Keine":text(branches.getFirst(),"br_name")+" ("+text(branches.getFirst(),"br_key")+")"; + var invoiceRows=db.queryForList("SELECT ca.*,a.* FROM costcenter cc JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=2 JOIN address a ON a.ad_id=ca.ad_id WHERE cc.csc_id=? AND cc.cs_id=?",id(row,"csc_id"),customer);if(invoiceRows.size()>1)throw bad("Die Rechnungsanschrift ist mehrdeutig."); + var invoice=invoiceRows.isEmpty()?Map.of():invoiceRows.getFirst(); + String payer=relation(u,true,id(row,"csc_id_payer")),parent=relation(u,false,id(row,"cs_id_parent")); + var sections=sections(row,account,tax,branch,invoice,payer,parent);String label=text(row,"cs_eid")+" · "+text(row,"cmp_comp"); + return new State(customer,label,sections,revision(List.of(customer,u.userId(),u.headquartersId(),label,sections))); + }); + } + String relation(LegacyUser u,boolean payer,long target){ + if(target<=0)return payer?"Selbst":"Keiner"; + var refs=new CustomerReferences(db);Map related; + try{related=refs.reference(payer,target,refs.scope(u),false);}catch(org.springframework.web.server.ResponseStatusException e){if(e.getStatusCode().value()!=400&&e.getStatusCode().value()!=404)throw e;return "Zuordnung #"+target+" (nicht zugänglich)";} + return text(related,"number")+" · "+text(related,"name")+(payer?" · "+text(related,"costCenter"):""); + } + static String yes(Map row,String key){return id(row,key)==1?"Ja":"Nein";} + static String number(Map row,String key){var d=decimal(row,key);return d==null?"0":d.stripTrailingZeros().toPlainString().replace('.',',');} + static String address(Map row,String house){return String.join("\n",text(row,"ad_street")+" "+text(row,house),text(row,"ad_zipcode")+" "+text(row,"ad_city"),text(row,"ad_country")).strip();} + static Section section(String title,String... pairs){var items=new ArrayList();for(int i=0;i sections(Map r,Map account,Map tax,String branch,Map invoice,String payer,String parent){ + var out=new ArrayList
();out.add(section("Kunde","Firma",text(r,"cmp_comp"),"Zusatz 1",text(r,"cmp_comp2"),"Kundennummer",text(r,"cs_eid"))); + out.add(section("Adresse und Kontakt","Anschrift",address(r,"cmp_hsno"),"E-Mail",text(r,"usr_email"),"Telefon",text(r,"usr_phone"),"Mobiltelefon",text(r,"usr_phone2"),"Fax",text(r,"usr_fax"),"Nachname",text(r,"usr_name"),"Vorname",text(r,"usr_firstname"),"Zugangskonto",text(account,"usr_account"))); + out.add(section("Bank und Steuern","Steuernummer",text(r,"cmp_tax_idno"),"USt-IdNr.",text(r,"cmp_stax_idno"),"Bank",text(r,"cmp_bank"),"Kontonummer / BLZ",text(r,"cmp_bankacc")+" / "+text(r,"cmp_bankno"),"IBAN",text(r,"cmp_iban"),"BIC",text(r,"cmp_swift"),"Mehrwertsteuer",number(tax,"tx_value")+" % ("+text(tax,"tx_sign")+")")); + out.add(section("Status und Benachrichtigungen","Freigegeben",yes(r,"cmp_authenticated"),"Sichtbar",yes(r,"cmp_visible"),"E-Mail bei Erledigung",yes(r,"cs_jbstatusmail"),"E-Mail bei Abholung",yes(r,"cs_jbstatusmail2"),"E-Mail bei Erfassung",yes(r,"cs_jbstatusmail3"))); + out.add(section("Konditionen","Rabatt",number(r,"cs_discount")+" %","Rabatt auf Fixpreis",yes(r,"cs_fixprice_discount"),"Porto",number(r,"cmp_postage")+" EUR","Provision",number(r,"cs_prov")+" %","Treibstoffaufschlag",number(r,"cs_markup_prov")+" % (aktiv: "+yes(r,"cs_markup")+")")); + out.add(section("Rechnung und Vermittlung","Rechnung an Zentrale",(id(r,"cmp_inv2hq")&1)!=0?"Ja":"Nein","Einzelrechnung",(id(r,"cmp_inv2hq")&2)!=0?"Ja":"Nein","Kommissionsnummer im Auftrag erforderlich",yes(r,"cs_commission_no"),"Listenkunde",yes(r,"cs_jobbatch"),"Nur angeforderte Kuriere",yes(r,"cs_cr_fav_only"),"Rechnungskurztext",yes(r,"cs_invtext_mode"))); + String differingAddress="Keine",differingCompany="Keine"; + if(!invoice.isEmpty()){ + if(!address(invoice,"cscad_hsno").equals(address(r,"cmp_hsno")))differingAddress=address(invoice,"cscad_hsno"); + if(!text(invoice,"cscad_comp").equals(text(r,"cmp_comp"))||!text(invoice,"cscad_comp2").equals(text(r,"cmp_comp2")))differingCompany=(text(invoice,"cscad_comp")+"\n"+text(invoice,"cscad_comp2")).strip(); + }else{differingAddress="Keine Rechnungsanschrift hinterlegt";differingCompany="Keine Rechnungsanschrift hinterlegt";} + out.add(section("Zuordnungen","Branche",branch,"Abweichende Rechnungsanschrift",differingAddress,"Abweichende Rechnungsfirma",differingCompany,"Drittzahler",payer,"Übergeordneter Kunde (Metakunde)",parent)); + out.add(section("Bemerkungen","Bemerkung",text(r,"cmp_remark"),"Interne Bemerkung",text(r,"cmp_remark2")));return List.copyOf(out); + } + static String html(State state){var out=new StringBuilder("");for(var section:state.sections()){out.append("

").append(OrderCustomerMailContent.escape(section.title())).append("

");for(var item:section.items())out.append(CustomerOrderDocument.row(item.label(),item.value()));out.append("
");}return out.append("").toString();} + static void unchanged(State state,String expected){if(expected!=null&&!state.revision().equals(expected))throw new org.springframework.web.server.ResponseStatusException(HttpStatus.CONFLICT,"Das Kundenstammblatt hat sich geändert. Bitte die Vorschau neu laden.");} + Map preview(LegacyUser u,long customer,Preview request){if(request==null||request.page()<0)throw bad("Ungültige PDF-Seite.");var state=state(u,customer);unchanged(state,request.revision());var result=new LinkedHashMap<>(VehicleRollcardPdf.page(OrderMailPdf.create(customer,html(state)),request.page()));result.putAll(fields("label",state.label(),"revision",state.revision()));return result;} + byte[] pdf(LegacyUser u,long customer,String expected){if(expected==null||!expected.matches("[a-f0-9]{64}"))throw bad("Bitte zuerst die PDF-Vorschau öffnen.");var state=state(u,customer);unchanged(state,expected);return OrderMailPdf.create(customer,html(state));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerStandardPricing.java b/backend/src/main/java/de/stadtbote/votian/CustomerStandardPricing.java new file mode 100644 index 0000000..e14b0f6 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerStandardPricing.java @@ -0,0 +1,14 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; + +/** The two jobs2 switches suppress base and route tariffs, but preserve explicit prices. */ +record CustomerStandardPricing(boolean noStandardPrice,boolean calculatorOnly) { + static final CustomerStandardPricing ENABLED=new CustomerStandardPricing(false,false); + boolean disabled(){return noStandardPrice||calculatorOnly;} + static CustomerStandardPricing read(DatabaseService db,OrderEntryCatalog catalog,LegacyUser user,long customer){ + if(!user.customerAccount())return ENABLED; + return new CustomerStandardPricing(new CustomerEntryAccess(db).unscoped("MASK_CS_DONT_MAKE_STANDARD_PRICE_"+customer).equals("1"), + catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+customer)); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerStatement.java b/backend/src/main/java/de/stadtbote/votian/CustomerStatement.java new file mode 100644 index 0000000..6dc0fb2 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerStatement.java @@ -0,0 +1,93 @@ +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.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.BillingService.*; + +/** Read-only customer invoice overview. A statement never assigns invoice numbers or posts money. */ +@RestController +@RequestMapping("/api/customer-portal/statement") +class CustomerStatement { + record Query(LocalDate from,LocalDate to,long center,boolean descendants,int page) {} + record Detail(Query query,long job) {} + record Scope(long customer,long base,long root,long headquarters,String name,List> centers,Set allowed) {} + record Amount(List> lines,BigDecimal exact) {} + final DatabaseService db; + CustomerStatement(DatabaseService db){this.db=db;} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @PostMapping Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @PostMapping("/detail") Object detail(Authentication a,@RequestBody Detail d){return detail(ApiController.user(a),d);} + + Scope scope(LegacyUser u){ + var a=CustomerPortalScope.verify(db,u);if(!u.customerRight(5)||!EmployeeAdministration.bit(text(a,"emp_rights"),5))throw CustomerPortalScope.denied(); + long customer=id(a,"cs_id"),base=id(a,"csc_id"),root=id(a,"customer_root"); + var tree=db.queryForList("SELECT csc_id AS id,csc_pre_id AS parent,csc_name AS label FROM costcenter WHERE cs_id=? ORDER BY csc_name,csc_id LIMIT 10001",customer);if(tree.size()>10000)throw bad("Zu viele Kostenstellen für die Abrechnungsübersicht."); + var valid=PortalEmployees.validCenters(tree,root);var allowed=new HashSet<>(PortalEmployees.validCenters(tree,base));allowed.retainAll(valid); + var byId=new HashMap>();tree.forEach(r->byId.put(id(r,"id"),r)); + allowed.removeIf(center->{var seen=new HashSet();long current=center;while(current>0){if(!seen.add(current)||!byId.containsKey(current))return true;var r=byId.get(current);if(Set.of("EXTERN","PRIVAT").contains(text(r,"label").toUpperCase(Locale.ROOT)))return true;current=id(r,"parent");}return false;}); + if(!allowed.contains(base))throw CustomerPortalScope.denied();var names=db.queryForList("SELECT cmp_comp FROM company WHERE cmp_id=?",id(a,"cmp_id")); + return new Scope(customer,base,root,u.headquartersId(),names.size()==1?text(names.getFirst(),"cmp_comp"):"Kunde "+customer,tree.stream().filter(r->allowed.contains(id(r,"id"))).toList(),Set.copyOf(allowed)); + } + Map options(LegacyUser u){return db.readTransaction(()->{var s=scope(u);return fields("name",s.name(),"center",s.base(),"centers",s.centers(),"from",LocalDate.now().withDayOfMonth(1),"to",LocalDate.now(),"descendants",true,"archiveAvailable",false,"amountLabel",s.headquarters()>1000?"Offener Betrag (brutto)":"Preis (netto)");});} + static Amount services(List> prices,boolean split){ + var lines=new ArrayList>();BigDecimal sum=BigDecimal.ZERO; + for(var p:prices){BigDecimal price=nz(decimal(p,"trs_price"));if(text(p,"trs_srvt_name").equals("z")&&price.signum()==0)continue; + BigDecimal amount=price.multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2);sum=sum.add(amount); + String label=(text(p,"trs_srv_name").replace("
"," ")+" "+text(p,"trs_srvt_name")).strip();lines.add(fields("label",label+(split?" (Kostenstellenanteil)":""),"amount",money(amount))); + } + return new Amount(lines,sum); + } + static Amount payments(Map job,List> payments){ + BigDecimal counter=BigDecimal.ZERO,cash=BigDecimal.ZERO,ec=BigDecimal.ZERO,tax=new BigDecimal("1.19"); + for(var p:payments){BigDecimal amount=nz(decimal(p,"jbp_price"));if(id(p,"jbp_counter")==1)counter=counter.add(amount);else if(id(p,"jbp_mode")==0)cash=cash.add(amount);else if(id(p,"jbp_mode")==1)ec=ec.add(amount);} + BigDecimal due=money(nz(decimal(job,"jb_totalprice")).multiply(tax)).subtract(money(counter.multiply(tax))),bar=money(cash.multiply(tax)),card=money(ec.multiply(tax)); + return new Amount(List.of(fields("label","Soll","amount",due),fields("label","EC","amount",card),fields("label","Bar","amount",bar)),due.subtract(card).subtract(bar)); + } + Map query(LegacyUser u,Query q){return query(u,q,0);} + Map query(LegacyUser u,Query q,long onlyJob){return db.readTransaction(()->{ + var s=scope(u);if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.to().isBefore(q.from())||q.from().plusYears(1).isBefore(q.to())||q.page()<0||q.page()>10000)throw bad("Bitte einen gültigen Zeitraum von höchstens einem Jahr wählen.");if(!s.allowed().contains(q.center()))throw CustomerPortalScope.denied(); + var selected=new TreeSet<>(q.descendants()?PortalEmployees.validCenters(s.centers(),q.center()):Set.of(q.center()));selected.retainAll(s.allowed());String marks=CourierPortal.placeholders(selected.size()); + var args=new ArrayList(List.of(s.headquarters(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay()));args.addAll(selected);args.addAll(selected);args.addAll(selected);if(onlyJob>0)args.add(onlyJob); + var jobs=db.queryForList("SELECT "+CustomerPendingPrice.SQL.replace("jb.","job.")+" AS price_pending,jb_id,csc_id,csc_id_payer,jb_payment,emp_id,cr_sid,vht_id,jb_ordertime,jb_finishtime,jb_booktime,jb_export_time,jb_incomplete,jb_totalprice FROM job WHERE hq_id=? AND jb_status=2 AND COALESCE(jb_storno,0)<2 AND jb_finishtime>=? AND jb_finishtime1 AND csc_id_payer IN ("+marks+")) OR ((csc_id_payer=0 OR jb_payment=1) AND (EXISTS (SELECT 1 FROM tour t WHERE t.jb_id=job.jb_id AND t.csc_id IN ("+marks+")) OR EXISTS (SELECT 1 FROM tourservice ts WHERE ts.jb_id=job.jb_id AND ts.csc_id IN ("+marks+"))))) "+(onlyJob>0?"AND jb_id=? ":"")+"ORDER BY jb_finishtime,jb_id LIMIT 2001",args.toArray()); + if(jobs.size()>2000)throw bad("Mehr als 2.000 Aufträge gefunden. Bitte Zeitraum oder Kostenstelle eingrenzen."); + var rows=new ArrayList>();var warnings=new ArrayList();BigDecimal total=BigDecimal.ZERO; + var prices=new HashMap>>();var payments=new HashMap>>();var creators=new HashMap>();var vehicles=new HashMap(); + if(!jobs.isEmpty()){ + var ids=jobs.stream().map(j->id(j,"jb_id")).toList();String jobMarks=CourierPortal.placeholders(ids.size()); + for(var p:db.queryForList("SELECT jb_id,csc_id,tr_sort,srv_id,srvt_id,trs_srv_name,trs_srvt_name,trs_price,trs_discount FROM tourservice WHERE jb_id IN ("+jobMarks+") ORDER BY jb_id,csc_id,tr_sort,srv_id,srvt_id",ids.toArray()))prices.computeIfAbsent(id(p,"jb_id"),i->new ArrayList<>()).add(p); + if(s.headquarters()>1000)for(var p:db.queryForList("SELECT jb_id,jbp_price,jbp_mode,jbp_counter FROM jobpayment WHERE jb_id IN ("+jobMarks+")",ids.toArray()))payments.computeIfAbsent(id(p,"jb_id"),i->new ArrayList<>()).add(p); + var users=jobs.stream().map(j->id(j,"emp_id")).filter(id->id>0).distinct().toList();if(!users.isEmpty())for(var p:db.queryForList("SELECT user.usr_id,user.usr_name,employee.csc_id FROM user JOIN employee ON employee.usr_id=user.usr_id WHERE user.usr_type=2 AND user.hq_id=? AND user.usr_id IN ("+CourierPortal.placeholders(users.size())+")",concat(s.headquarters(),users)))if(s.allowed().contains(id(p,"csc_id")))creators.put(id(p,"usr_id"),p); + for(var v:db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='vehicletype'"))vehicles.putIfAbsent(id(v,"mt_sort"),text(v,"mt_value")); + } + var names=new HashMap();s.centers().forEach(c->names.put(id(c,"id"),id(c,"id")==s.root()?s.name():text(c,"label"))); + for(var job:jobs){long id=id(job,"jb_id"),payer=id(job,"csc_id_payer");boolean split=payer==0||id(job,"jb_payment")==1,pricePending=id(job,"price_pending")==1,paymentAllocationPending=split&&s.headquarters()>1000&&!pricePending;var jobPrices=prices.getOrDefault(id,List.of());var groups=new TreeMap>>(); + if(split){for(var p:jobPrices)if(selected.contains(id(p,"csc_id")))groups.computeIfAbsent(id(p,"csc_id"),i->new ArrayList<>()).add(p);if(groups.isEmpty()&&(pricePending||paymentAllocationPending))for(var stop:db.queryForList("SELECT DISTINCT csc_id FROM tour WHERE jb_id=?",id))if(selected.contains(id(stop,"csc_id")))groups.put(id(stop,"csc_id"),List.of());if(groups.isEmpty()){warnings.add("Auftrag #"+id+": keine Leistungspositionen für die ausgewählten Kostenstellen.");continue;}} + else groups.put(payer,jobPrices); + if(paymentAllocationPending)warnings.add("Auftrag #"+id+": Zahlungszuordnung für den Kostensplit offen. Der Auftrag ist nicht in der Summe geklärter Beträge enthalten."); + if(pricePending)warnings.add("Auftrag #"+id+": Preis in Prüfung. Der Auftrag ist nicht in der Summe geklärter Preise enthalten."); + for(var group:groups.entrySet()){ + Amount amount=s.headquarters()>1000&&!paymentAllocationPending?payments(job,payments.getOrDefault(id,List.of())):services(group.getValue(),split);if(!pricePending&&!paymentAllocationPending)total=total.add(amount.exact());if(group.getValue().isEmpty()&&s.headquarters()<=1000&&nz(decimal(job,"jb_totalprice")).signum()!=0)warnings.add("Auftrag #"+id+": keine gespeicherten Leistungspositionen vorhanden."); + var creator=creators.get(id(job,"emp_id"));String orderedBy=creator==null?"(System)":names.getOrDefault(id(job,"csc_id"),"")+", "+text(creator,"usr_name"); + rows.add(fields("id",id,"center",group.getKey(),"payer",names.getOrDefault(group.getKey(),"Kostenstelle"),"orderedBy",orderedBy,"ordered",dateTime(job,"jb_ordertime"),"finished",dateTime(job,"jb_finishtime"),"booked",dateTime(job,"jb_booktime"),"courier",text(job,"cr_sid"),"vehicle",vehicles.getOrDefault(id(job,"vht_id"),""),"incomplete",id(job,"jb_incomplete")==1,"exported",dateTime(job,"jb_export_time")!=null,"split",split,"pricePending",pricePending,"paymentAllocationPending",paymentAllocationPending,"lines",pricePending||paymentAllocationPending?amount.lines().stream().map(l->fields("label",l.get("label"),"amount",null)).toList():amount.lines(),"amount",pricePending||paymentAllocationPending?null:money(amount.exact()))); + } + } + int from=Math.min(q.page()*50,rows.size()),to=Math.min(from+50,rows.size());return fields("rows",rows.subList(from,to),"page",q.page(),"hasNext",toid(r,"id")).distinct().count(),"total",money(total),"pendingJobs",jobs.stream().filter(j->id(j,"price_pending")==1).count(),"allocationPendingJobs",rows.stream().filter(r->Boolean.TRUE.equals(r.get("paymentAllocationPending"))).map(r->id(r,"id")).distinct().count(),"warnings",warnings,"amountLabel",s.headquarters()>1000?"Offener Betrag (brutto)":"Preis (netto)"); + });} + @SuppressWarnings("unchecked") + Map detail(LegacyUser u,Detail d){return db.readTransaction(()->{ + if(d==null||d.query()==null||d.job()<=0)throw bad("Auftrag fehlt.");var q=d.query(); + var report=query(u,new Query(q.from(),q.to(),q.center(),false,0),d.job());var rows=(List>)report.get("rows"); + if(rows.size()!=1)throw CustomerPortalScope.denied();var row=rows.getFirst();boolean split=Boolean.TRUE.equals(row.get("split")); + var jobs=db.queryForList("SELECT jb_id,jb_status,jb_storno,jb_ordertime,jb_finishtime,jb_invtext,cr_sid FROM job WHERE jb_id=? AND hq_id=?",d.job(),u.headquartersId());if(jobs.size()!=1)throw CustomerPortalScope.denied();var job=jobs.getFirst(); + var stops=db.queryForList(CustomerPortal.STOP_SQL+(split?" AND t.csc_id=?":"")+" ORDER BY t.tr_sort,t.tr_id",split?new Object[]{d.job(),q.center()}:new Object[]{d.job()}); + return fields("id",d.job(),"status",id(job,"jb_status"),"cancelled",id(job,"jb_storno"),"ordered",dateTime(job,"jb_ordertime"),"finished",dateTime(job,"jb_finishtime"),"pricePending",row.get("pricePending"),"paymentAllocationPending",row.get("paymentAllocationPending"),"price",row.get("amount"),"priceLabel",split?"Kostenstellenanteil":report.get("amountLabel"),"vehicle",text(job,"cr_sid"),"invoiceText",split||Boolean.TRUE.equals(row.get("pricePending"))?"":text(job,"jb_invtext"),"scopeNotice",split?"Es werden nur Leistungen und Stationen dieser Kostenstelle angezeigt."+(Boolean.TRUE.equals(row.get("paymentAllocationPending"))?" Die Zahlungszuordnung ist offen; ein offener Betrag für diese Kostenstelle kann noch nicht ausgewiesen werden.":""):"","stops",CustomerPortal.publicStops(stops)); + });} + static Object[] concat(long first,List rest){var args=new ArrayList();args.add(first);args.addAll(rest);return args.toArray();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerStationArticles.java b/backend/src/main/java/de/stadtbote/votian/CustomerStationArticles.java new file mode 100644 index 0000000..d02346d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerStationArticles.java @@ -0,0 +1,24 @@ +package de.stadtbote.votian; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** getOutputJobTratArticleData with noDefaultCols/noSummation for customer POD stations. */ +final class CustomerStationArticles { + static Map table(OrderCustomerMailContent cfg,OrderDeliveryData data,long order,int position){ + String configured=cfg.fallback("MASK_JOBDETAILS_ARTICLES");String names=cfg.fallback("MASK_JOBDETAILS_ARTICLE_HEADLINE");var labels=names.isBlank()?CustomerOrderItems.LABELS:Arrays.asList(names.split(",",-1));var indexes=new LinkedHashSet(); + if(!configured.isBlank())for(String part:configured.split(",",-1)){ + if(!part.strip().matches("[0-9]{1,2}"))throw bad("Die Artikelspalten der Stationsnachweise sind ungültig.");int i=Integer.parseInt(part.strip()); + // PHP reads this process field but its output is commented out. + if(i==99)continue;if(i>=CustomerOrderItems.KEYS.size())throw bad("Unbekannte Artikelspalte in den Stationsnachweisen.");indexes.add(i); + } + if(cfg.fallback("MASK_JOBDETAILS_ARTICLE_SCAN_EVENTS").equals("1"))indexes.add(4); + if(indexes.isEmpty())return fields("columns",List.of(),"rows",List.of()); + var articles=data.articles(order,position);if(articles.size()>1000)throw bad("Mehr als 1.000 Artikel an einer Station."); + var columns=indexes.stream().map(i->fields("label",i{var cells=new ArrayList();for(int i:indexes){String value=text(a,CustomerOrderItems.KEYS.get(i));if(i==6||i==9)value=OrderDeliveryData.publicRemark(value);if(i==9&&value.codePointCount(0,value.length())>50)value=value.substring(0,value.offsetByCodePoints(0,50));cells.add(value);}return fields("id",id(a,"trat_id"),"cells",cells);}).toList(); + return fields("columns",columns,"rows",rows); + } + private CustomerStationArticles(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerStatistics.java b/backend/src/main/java/de/stadtbote/votian/CustomerStatistics.java new file mode 100644 index 0000000..432ce84 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerStatistics.java @@ -0,0 +1,100 @@ +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.BigDecimal; +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; + +/** The customer UI exposes formats 60/61 and customer turnover only (statistic_interface_cs.inc.php). */ +@RestController +@RequestMapping("/api/customer-portal/statistics") +class CustomerStatistics { + enum Split { NONE, VEHICLE, START_POSTAL, END_POSTAL, VEHICLE_START_POSTAL, VEHICLE_END_POSTAL } + record Query(LocalDate from, LocalDate to, boolean monthly, int timeSplit, Split split, + boolean splitCenter, boolean splitHeadquarters, boolean groupCustomers, List headquarters, + String vehicle, LocalTime timeFrom, LocalTime timeTo, int comparisons, String sort, boolean descending) {} + record Scope(long customer, long mandator, boolean canDownload, boolean meta, List> headquarters, int comparisons) {} + record Column(String key,String label,String type) {} + final DatabaseService db; + final OrderEntryCatalog catalog; + CustomerStatistics(DatabaseService db){this.db=db;this.catalog=new OrderEntryCatalog(db);} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @PostMapping("/query") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @PostMapping("/download") Object download(Authentication a,@RequestBody Query q){return download(ApiController.user(a),q);} + + Scope scope(LegacyUser u){ + var account=CustomerPortalScope.verify(db,u);String rights=text(account,"emp_rights"); + if(!u.customerRight(8)||!EmployeeAdministration.bit(rights,8))throw CustomerPortalScope.denied(); + long customer=id(account,"cs_id"),md=mandator(db,u); + boolean meta=!db.queryForList("SELECT cs_id FROM customer WHERE cs_id_parent=? AND cs_id<>? AND hq_id IN ("+CourierBourse.HQ_SQL+") LIMIT 1",customer,customer,md).isEmpty(); + var hqs=db.queryForList("SELECT hq_id AS id,hq_mnemonic AS label FROM headquarters WHERE hq_id IN ("+CourierBourse.HQ_SQL+") AND COALESCE(hq_disabled,0)=0"+(meta?"":" AND hq_id=?")+" ORDER BY hq_mnemonic,hq_id",meta?new Object[]{md}:new Object[]{md,u.headquartersId()}); + int comparisons=0;if(catalog.enabled(0,"STATISTIC_INTERVALS_ENABLED"))try{comparisons=Math.max(0,Math.min(10,Integer.parseInt(catalog.parameter(0,"STATISTIC_INTERVALS_NUMBER_OF"))));}catch(NumberFormatException ignored){} + return new Scope(customer,md,u.customerRight(10)&&EmployeeAdministration.bit(rights,10),meta,hqs,comparisons); + } + Map options(LegacyUser u){return db.readTransaction(()->{var s=scope(u);return fields("from",LocalDate.now().withDayOfMonth(1),"to",LocalDate.now(),"headquarters",s.headquarters(),"currentHeadquarters",u.headquartersId(),"meta",s.meta(),"canDownload",s.canDownload(),"maxComparisons",s.comparisons(),"excludeFreeCancellations",catalog.enabled(u.headquartersId(),"STATISTIC_NO_STORNOJOBS"),"relatedCustomer",catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"));});} + + static void validate(Query q){ + if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())||q.split()==null||!Set.of(0,30,60,120).contains(q.timeSplit())||q.comparisons()<0||q.comparisons()>10||q.headquarters()==null||q.headquarters().size()>500||q.headquarters().stream().anyMatch(Objects::isNull)||q.vehicle()==null||q.vehicle().length()>40||q.sort()==null)throw bad("Bitte einen gültigen Statistikzeitraum von höchstens einem Jahr und gültige Filter wählen."); + if((q.timeFrom()==null)!=(q.timeTo()==null)||q.timeFrom()!=null&&(q.timeFrom().isAfter(q.timeTo())||q.timeFrom().getSecond()!=0||q.timeTo().getSecond()!=0||q.timeFrom().getNano()!=0||q.timeTo().getNano()!=0))throw bad("Bitte ein gültiges tägliches Zeitintervall von/bis angeben."); + } + static List columns(Query q){ + var cols=new ArrayList<>(List.of(new Column("number","Kundennummer","text"),new Column("company","Firma","text"),new Column("additional","Zusatz","text"))); + if(q.monthly())cols.add(new Column("month","Monat","text")); + if(q.timeSplit()>0)cols.add(new Column("time","Zeitintervall","text")); + if(q.split()==Split.VEHICLE||q.split()==Split.VEHICLE_START_POSTAL||q.split()==Split.VEHICLE_END_POSTAL)cols.add(new Column("vehicle","Fahrzeugtyp","text")); + if(q.split()==Split.START_POSTAL||q.split()==Split.VEHICLE_START_POSTAL)cols.add(new Column("postal","Start-PLZ","text")); + if(q.split()==Split.END_POSTAL||q.split()==Split.VEHICLE_END_POSTAL)cols.add(new Column("postal","Ziel-PLZ","text")); + if(q.splitHeadquarters())cols.add(new Column("headquarters","Niederlassung","text")); + if(q.splitCenter())cols.add(new Column("center","Bezahlende Kostenstelle","text")); + cols.add(new Column("orders","Aufträge","number"));cols.add(new Column("amount","Auftragswert netto","money"));return cols; + } + Map query(LegacyUser u,Query q){return db.readTransaction(()->report(u,q,false));} + Map download(LegacyUser u,Query q){return db.readTransaction(()->{var report=report(u,q,true);return fields("filename","votian-kundenstatistik.csv","content",csv(report));});} + Map report(LegacyUser u,Query q,boolean download){ + var s=scope(u);if(download&&!s.canDownload())throw CustomerPortalScope.denied();validate(q); + var allowed=s.headquarters().stream().map(r->id(r,"id")).toList();if(!allowed.containsAll(q.headquarters())||q.groupCustomers()&&!s.meta()||q.splitHeadquarters()&&!s.meta())throw CustomerPortalScope.denied(); + if(q.comparisons()>s.comparisons())throw bad("Die angeforderten Vergleichsjahre sind nicht freigegeben."); + var columns=columns(q);if(columns.stream().noneMatch(c->c.key().equals(q.sort())))throw bad("Ungültige Statistiksortierung."); + var periods=new ArrayList>();for(int year=0;year<=q.comparisons();year++){ + LocalDate from=q.from().minusYears(year),to=q.to().minusYears(year);var rows=rows(u,s,q,from,to); + Comparator> order=Set.of("orders","amount").contains(q.sort())?Comparator.comparing(r->decimal(r,q.sort())):Comparator.comparing(r->text(r,q.sort()),String.CASE_INSENSITIVE_ORDER); + if(q.descending())order=order.reversed();rows.sort(order.thenComparing(r->text(r,"number")).thenComparing(r->text(r,"month")).thenComparing(r->text(r,"center"))); + BigDecimal amount=BigDecimal.ZERO;long count=0;for(var row:rows){amount=amount.add(decimal(row,"amount"));count+=id(row,"orders");row.put("amount",BillingService.money(decimal(row,"amount")));} + periods.add(fields("from",from,"to",to,"rows",rows,"orders",count,"amount",BillingService.money(amount))); + } + return fields("columns",columns,"periods",periods,"canDownload",s.canDownload(),"excludedCancellations",catalog.enabled(u.headquartersId(),"STATISTIC_NO_STORNOJOBS"),"relatedCustomer",!q.splitCenter()&&catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")); + } + List> rows(LegacyUser u,Scope s,Query q,LocalDate from,LocalDate to){ + if(q.headquarters().isEmpty())return new ArrayList<>(); + String payer=!q.splitCenter()&&catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")?"jb.csc_id_related":"jb.csc_id_payer"; + var select=new ArrayList<>(List.of("cs.cs_eid AS number","cmp.cmp_comp AS company","cmp.cmp_comp2 AS additional","COUNT(*) AS orders","COALESCE(SUM(jb.jb_totalprice),0) AS amount")); + var groups=new ArrayList<>(List.of("cs.cs_id","cs.cs_eid","cmp.cmp_comp","cmp.cmp_comp2")); + if(q.monthly()){select.add("DATE_FORMAT(jb.jb_finishtime,'%Y-%m') AS month");groups.add("month");} + if(q.timeSplit()>0){select.add("FLOOR(TIME_TO_SEC(TIME(jb.jb_finishtime))/"+(q.timeSplit()*60)+") AS time_bucket");groups.add("time_bucket");} + if(q.split()==Split.VEHICLE||q.split()==Split.VEHICLE_START_POSTAL||q.split()==Split.VEHICLE_END_POSTAL){select.add("jb.vht_id AS vehicle_id");groups.add("jb.vht_id");} + boolean start=q.split()==Split.START_POSTAL||q.split()==Split.VEHICLE_START_POSTAL; + if(start){select.add("COALESCE((SELECT a.ad_zipcode FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND t.tr_sort=1 ORDER BY t.tr_id LIMIT 1),'') AS postal");groups.add("postal");} + else if(q.split()==Split.END_POSTAL||q.split()==Split.VEHICLE_END_POSTAL){select.add("COALESCE(SUBSTRING_INDEX(SUBSTRING(jb.jb_tourdata,1,INSTR(jb.jb_tourdata,CHAR(124))-1),CHAR(59),-1),'') AS postal");groups.add("postal");} + if(q.splitHeadquarters()){select.add("jb.hq_id AS headquarters_id");groups.add("jb.hq_id");} + if(q.splitCenter()){select.add("cc.csc_name AS center");groups.addAll(List.of("cc.csc_id","cc.csc_name"));} + var args=new ArrayList();args.addAll(q.headquarters().stream().distinct().toList());args.add(s.mandator());args.add(s.customer());args.add(from.atStartOfDay());args.add(to.plusDays(1).atStartOfDay()); + var where=new StringBuilder(" FROM job jb JOIN costcenter cc ON cc.csc_id="+payer+" JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE jb.jb_status=2 AND jb.hq_id IN ("+CourierPortal.placeholders(new HashSet<>(q.headquarters()).size())+") AND cs.hq_id IN ("+CourierBourse.HQ_SQL+") AND "+(q.groupCustomers()?"cs.cs_id_parent":"cs.cs_id")+"=? AND jb.jb_finishtime>=? AND jb.jb_finishtime?");args.add(s.customer());} + if(q.timeFrom()!=null){where.append(" AND TIME(jb.jb_finishtime)>=? AND TIME(jb.jb_finishtime)<=?");args.add(q.timeFrom());args.add(q.timeTo().withSecond(59));} + if(!q.vehicle().isBlank()){where.append(" AND jb.cr_sid=?");args.add(q.vehicle().strip());} + if(start)where.append(" AND EXISTS (SELECT 1 FROM tour ft JOIN address fa ON fa.ad_id=ft.ad_id WHERE ft.jb_id=jb.jb_id AND ft.tr_sort=1)"); + if(catalog.enabled(u.headquartersId(),"STATISTIC_NO_STORNOJOBS"))where.append(" AND COALESCE(jb.jb_storno,0) IN (0,1,3)"); + var rows=db.queryForList("SELECT "+String.join(",",select)+where+" GROUP BY "+String.join(",",groups)+" LIMIT 5001",args.toArray());if(rows.size()>5000)throw bad("Mehr als 5.000 Statistikzeilen. Bitte den Zeitraum oder die Aufteilung eingrenzen."); + var vehicleNames=new HashMap();if(select.contains("jb.vht_id AS vehicle_id"))for(var v:db.queryForList("SELECT mt_sort,MIN(mt_value) AS label FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort"))vehicleNames.put(id(v,"mt_sort"),text(v,"label")); + var headquarters=new HashMap();s.headquarters().forEach(h->headquarters.put(id(h,"id"),text(h,"label"))); + var result=new ArrayList>();for(var raw:rows){var row=new LinkedHashMap<>(raw);if(q.timeSplit()>0){row.put("time",timeLabel((int)id(row,"time_bucket"),q.timeSplit()));row.remove("time_bucket");}if(row.containsKey("vehicle_id")){row.put("vehicle",vehicleNames.getOrDefault(id(row,"vehicle_id"),"Typ "+id(row,"vehicle_id")));row.remove("vehicle_id");}if(q.splitHeadquarters()){row.put("headquarters",headquarters.getOrDefault(id(row,"headquarters_id"),"Niederlassung "+id(row,"headquarters_id")));row.remove("headquarters_id");}result.add(row);}return result; + } + static String timeLabel(int bucket,int minutes){int from=bucket*minutes,to=from+minutes-1;return "%02d:%02d – %02d:%02d".formatted(from/60,from%60,to/60,to%60);} + static String cell(Object v){String s=Objects.toString(v,"");if(!(v instanceof Number)&&!s.stripLeading().isEmpty()&&"=+@-".indexOf(s.stripLeading().charAt(0))>=0)s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";} + @SuppressWarnings("unchecked") static String csv(Map report){var columns=(List)report.get("columns");var lines=new ArrayList();var header=new ArrayList<>(List.of("Von","Bis"));columns.forEach(c->header.add(c.label()));lines.add(header.stream().map(CustomerStatistics::cell).collect(java.util.stream.Collectors.joining(";")));for(var period:(List>)report.get("periods"))for(var row:(List>)period.get("rows")){var values=new ArrayList(List.of(period.get("from"),period.get("to")));columns.forEach(c->values.add(row.get(c.key())));lines.add(values.stream().map(CustomerStatistics::cell).collect(java.util.stream.Collectors.joining(";")));}return "\uFEFF"+String.join("\r\n",lines)+"\r\n";} +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerTwoFactor.java b/backend/src/main/java/de/stadtbote/votian/CustomerTwoFactor.java new file mode 100644 index 0000000..3c788e4 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerTwoFactor.java @@ -0,0 +1,66 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import jakarta.servlet.http.HttpSession; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.security.SecureRandom; +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; + +/** Customer self-service uses the second USERTYPE_2FA_ENABLED flag, never central employee rights. */ +@RestController +@RequestMapping("/api/customer-portal/two-factor") +class CustomerTwoFactor { + static final String PENDING="votian.customer.twoFactor.pending"; + record State(Map account,boolean enabled){} + final DatabaseService db;final BusinessSupport support;final EmployeeAccounts passwords;final Clock clock; + @org.springframework.beans.factory.annotation.Autowired CustomerTwoFactor(DatabaseService db,BusinessSupport support,EmployeeAccounts passwords){this(db,support,passwords,Clock.systemUTC());} + CustomerTwoFactor(DatabaseService db,BusinessSupport support,EmployeeAccounts passwords,Clock clock){this.db=db;this.support=support;this.passwords=passwords;this.clock=clock;} + @GetMapping Object view(Authentication a){return view(ApiController.user(a));} + @PostMapping("/setup") Object setup(Authentication a,HttpSession session,@RequestBody AccountTwoFactor.Setup r){return setup(ApiController.user(a),session,r);} + @PostMapping("/activate") Object activate(Authentication a,HttpSession session,@RequestBody AccountTwoFactor.Verify r){return activate(ApiController.user(a),session,r);} + @PostMapping("/cancel") Object cancel(Authentication a,HttpSession session,@RequestBody AccountTwoFactor.Cancel r){return cancel(ApiController.user(a),session,r);} + @PostMapping("/reset") Object reset(Authentication a,@RequestBody AccountTwoFactor.Reset r){return reset(ApiController.user(a),r);} + State state(LegacyUser u){ + var c=CustomerPortalScope.context(db,u);var flags=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key='USERTYPE_2FA_ENABLED'"); + if(flags.size()>1)throw bad("Die Zwei-Faktor-Vorgaben sind nicht eindeutig.");String[] parts=(flags.isEmpty()?"":text(flags.getFirst(),"par_value")).split(",",-1);String flag=parts.length>1?parts[1].strip():"";if(!Set.of("","0","1").contains(flag))throw bad("Die Zwei-Faktor-Vorgaben für Kunden sind ungültig.");return new State(c.account(),flag.equals("1")); + } + static boolean active(State s){return id(s.account(),"usr_totp_activated")==1;} + static Object version(State s){var a=s.account();return fields("user",id(a,"usr_id"),"employee",id(a,"emp_id"),"hq",id(a,"hq_id"),"customer",id(a,"cs_id"),"center",id(a,"csc_id"),"root",id(a,"customer_root"),"credentials",text(a,"credential_stamp"),"enabled",s.enabled());} + Map view(LegacyUser u){return db.readTransaction(()->{var s=state(u);return fields("revision",revision(version(s)),"active",active(s),"canSetup",s.enabled()&&!active(s),"canReset",active(s),"selfDeactivationBlocked",false,"self",true,"account",text(s.account(),"usr_account"));});} + void authenticate(LegacyUser u,String password){if(!passwords.matches(u.userId(),password,true))throw bad("Das aktuelle eigene Passwort ist ungültig.");} + Map setup(LegacyUser u,HttpSession session,AccountTwoFactor.Setup r){ + if(r==null)throw bad("Einrichtungsangaben fehlen.");BusinessSupport.token(r.requestId());synchronized(session){return db.readTransaction(()->{ + var s=state(u);unchanged(version(s),r.revision());if(!s.enabled()||active(s))throw CustomerPortalScope.denied();authenticate(u,r.password()); + var old=session.getAttribute(PENDING);if(old instanceof AccountTwoFactor.Pending p&&p.user==u.userId()&&p.setupRequest.equals(r.requestId())&&p.revision.equals(r.revision())&&clock.instant().isBefore(p.expires))return setupView(s,p); + byte[] entropy=new byte[20];new SecureRandom().nextBytes(entropy);var p=new AccountTwoFactor.Pending(u.userId(),UUID.randomUUID().toString(),r.revision(),AccountTwoFactor.base32(entropy),r.requestId(),clock.instant().plusSeconds(600));session.setAttribute(PENDING,p);return setupView(s,p); + });} + } + static Map setupView(State s,AccountTwoFactor.Pending p){String account=text(s.account(),"usr_account");String uri="otpauth://totp/"+AccountTwoFactor.encode("Stadtbote:"+account)+"?secret="+p.secret+"&issuer=Stadtbote&algorithm=SHA1&digits=6&period=30";return fields("setupId",p.id,"secret",p.secret,"qrDataUrl",AccountTwoFactor.qr(uri),"expiresAt",p.expires.toString(),"account",account);} + Map cancel(LegacyUser u,HttpSession session,AccountTwoFactor.Cancel r){CustomerPortalScope.verify(db,u);synchronized(session){if(session.getAttribute(PENDING) instanceof AccountTwoFactor.Pending p&&p.user==u.userId()&&r!=null&&p.id.equals(r.setupId()))session.removeAttribute(PENDING);return fields("cancelled",true);}} + Map activate(LegacyUser u,HttpSession session,AccountTwoFactor.Verify r){ + if(r==null)throw bad("Bestätigung fehlt.");BusinessSupport.token(r.requestId());BusinessSupport.token(r.setupId());support.transactional("user","genericdatacontainer");synchronized(session){ + if(!(session.getAttribute(PENDING) instanceof AccountTwoFactor.Pending p)||p.user!=u.userId()||!p.id.equals(r.setupId())||!clock.instant().isBefore(p.expires))throw bad("Die Einrichtung ist abgelaufen. Bitte erneut beginnen.");if(p.attempts>=5)throw bad("Zu viele ungültige Codes. Bitte die Einrichtung erneut beginnen."); + return db.customerEmployeeWriteTransaction(false,()->{ + var s=state(u);Object intent=List.of("CUSTOMER_2FA_ACTIVATE",p.id,p.revision);Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null){if(!active(s)||!text(s.account(),"usr_totp_secret").equals(p.secret))throw bad("Die Zwei-Faktor-Verknüpfung wurde inzwischen geändert.");return fields("id",previous,"reauthenticate",true);} + unchanged(version(s),p.revision);if(!s.enabled()||active(s))throw CustomerPortalScope.denied();long seconds=clock.instant().getEpochSecond();if(!LegacyTotp.matches(p.secret,r.code(),seconds)){p.attempts++;throw bad("Der Code ist ungültig. Bitte den aktuellen sechsstelligen App-Code eingeben.");} + db.update("UPDATE user SET usr_totp_secret=?,usr_totp_activated=1,usr_totp_sessionkey=? WHERE usr_id=?",p.secret,PortalAccounts.sessionKey(),u.userId()); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",u.userId()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngtotp","gdc_obj_id",u.userId(),"gdc_gen_fieldname","login","gdc_content",Long.toString(seconds/30),"gdc_context",revision(p.secret))); + support.auditEmployee(u,u.employeeId(),74,"CUSTOMER="+id(s.account(),"cs_id")+" | TWO_FACTOR_ACTIVATED",r.requestId());support.receipt(u,r.requestId(),intent,u.employeeId());return fields("id",u.employeeId(),"reauthenticate",true); + }); + } + } + Map reset(LegacyUser u,AccountTwoFactor.Reset r){ + if(r==null)throw bad("Rücksetzungsangaben fehlen.");BusinessSupport.token(r.requestId());support.transactional("user","genericdatacontainer");return db.customerEmployeeWriteTransaction(false,()->{ + var s=state(u);authenticate(u,r.password());Object intent=List.of("CUSTOMER_2FA_RESET",Objects.toString(r.revision(),""),Objects.toString(r.account(),""));Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous,"reauthenticate",true); + unchanged(version(s),r.revision());if(!active(s)||!text(s.account(),"usr_account").equals(r.account()))throw bad("Bitte den aktiven Zwei-Faktor-Zugang korrekt bestätigen.");if(!LegacyTotp.matches(text(s.account(),"usr_totp_secret"),r.code(),clock.instant().getEpochSecond()))throw bad("Der aktuelle Zwei-Faktor-Code ist ungültig."); + // PHP start.php applies USER_2FA_NO_DEACTIVATION only to type 1, not customers. + db.update("UPDATE user SET usr_totp_secret='',usr_totp_activated=0,usr_totp_sessionkey=? WHERE usr_id=?",PortalAccounts.sessionKey(),u.userId());db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",u.userId());support.auditEmployee(u,u.employeeId(),184,"CUSTOMER="+id(s.account(),"cs_id")+" | TWO_FACTOR_RESET",r.requestId());support.receipt(u,r.requestId(),intent,u.employeeId());return fields("id",u.employeeId(),"reauthenticate",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/CustomerType.java b/backend/src/main/java/de/stadtbote/votian/CustomerType.java new file mode 100644 index 0000000..9730360 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/CustomerType.java @@ -0,0 +1,99 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Service; +import org.springframework.scheduling.annotation.Scheduled; +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; + +/** Customer/prospect transitions from customer_special.php and inc_customer.inc.php. */ +@Service +public class CustomerType { + final DatabaseService db; final BusinessSupport support; + public CustomerType(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + static final List LABELS=List.of("Kunde","Interessent","Potentialkunde","Wunschkunde"); + record Report(long headquarters,long customer,long user,int type,String text,String at){} + + List> options(){ + var labels=new TreeMap();for(long i=0;i<4;i++)labels.put(i,LABELS.get((int)i)); + for(var r:db.queryForList("SELECT mt_sort,MIN(mt_value) AS label FROM metatype WHERE mt_type='customer_type' AND mt_sort BETWEEN 0 AND 3 GROUP BY mt_sort ORDER BY mt_sort"))if(!text(r,"label").isBlank())labels.put(id(r,"mt_sort"),text(r,"label")); + return labels.entrySet().stream().>map(e->fields("id",e.getKey(),"label",e.getValue())).toList(); + } + boolean hasOrders(long customer,boolean lock){ + if(customer==0)return false; + String centers="SELECT csc_id FROM costcenter WHERE cs_id=? ORDER BY csc_id"; + for(var c:lock?db.lockRows(centers,customer):db.queryForList(centers,customer))for(String key:List.of("csc_id_payer","csc_id_payer_cash")){ + String query="SELECT jb_id FROM job WHERE "+key+"=? LIMIT 1"; + if(!(lock?db.lockRows(query,id(c,"csc_id")):db.queryForList(query,id(c,"csc_id"))).isEmpty())return true; + } + return false; + } + Map view(long customer,Map row){ + String since=text(row,"cs_become_cs_date");if(since.startsWith("0000-"))since=""; + return fields("hasOrders",hasOrders(customer,false),"storedType",id(row,"cmp_type"),"customerSince",since, + "pendingReports",customer==0?0:db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngcsrpt' AND gdc_obj_id=? AND gdc_context<>'SENT'",Long.class,customer)); + } + void prepare(long customer,Map old,Map clean){ + if(!clean.containsKey("cmp_type"))clean.put("cmp_type",id(old,"cmp_type")); + long next=id(clean,"cmp_type");if(next<0||next>3)throw bad("Ungültiger Kundentyp."); + if(next!=0&&hasOrders(customer,true))throw bad("Für diesen Kunden existieren bereits Zahleraufträge. Der Typ muss Kunde bleiben; bitte neu laden."); + if(next!=0)clean.put("cs_tracking",0); + } + void changed(LegacyUser actor,long customer,Map old,Map clean,boolean created,String token){ + int next=(int)id(clean,"cmp_type"),before=(int)id(old,"cmp_type");if(!created&&before==next)return; + LocalDateTime at=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0); + if(next==0)db.update("UPDATE customer SET cs_become_cs_date=? WHERE cs_id=?",at.toLocalDate(),customer); + String key=created?"CS_CMPTYPE2RPTYPE_NEW":next==0?"CS_CMPTYPE2RPTYPE_MODIFY_2":"CS_CMPTYPE2RPTYPE_MODIFY"; + var parameters=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key=?",key); + if(parameters.size()>1)throw bad("Mehrdeutiger Berichtstyp für Kundentypwechsel."); + int reportType=reportType(parameters.isEmpty()?"":text(parameters.getFirst(),"par_value"),!created&&next==0?2:1); + String label=options().stream().filter(r->id(r,"id")==next).map(r->text(r,"label")).findFirst().orElse(LABELS.get(next)); + var report=new Report(actor.headquartersId(),customer,actor.userId(),reportType,(created?"Neuanlage als ":"Änderung in ")+label,at.toString()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngcsrpt","gdc_obj_id",customer,"gdc_gen_fieldname",token,"gdc_content",support.encode(report),"gdc_context","PENDING")); + long creator=created?actor.userId():creator(customer,actor.userId()); + if(created)db.insert("genericdatacontainer",fields("gdc_obj_type","cs","gdc_obj_id",customer,"gdc_gen_fieldname","ng_type_creator","gdc_content",Long.toString(creator),"gdc_context","")); + var author=new LegacyUser(creator,0,actor.headquartersId(),actor.username(),actor.rights()); + support.audit(author,0,customer,0,95,"SRC=cs|OLD="+(created?"":before)+"|NEW="+next,token); + } + static int reportType(String value,int fallback){ + if(value.isBlank())return fallback; + try{int n=Integer.parseInt(value.strip());if(n<0||n>127)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad("Ungültiger Berichtstyp für Kundentypwechsel.");} + } + long creator(long customer,long fallback){ + var stored=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='cs' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_type_creator'",customer); + if(stored.size()>1)throw bad("Mehrdeutiger Ersteller des Kundenstamms."); + if(!stored.isEmpty()){try{long id=Long.parseLong(text(stored.getFirst(),"gdc_content"));if(id>0)return id;}catch(NumberFormatException ignored){}throw bad("Ungültiger Ersteller des Kundenstamms.");} + var logs=db.queryForList("SELECT usr_id FROM "+support.historySchema+".log WHERE logo_id=95 AND cs_id=? AND logo_description LIKE ? ORDER BY log_id LIMIT 1",customer,"%|OLD=|%"); + return logs.isEmpty()||id(logs.getFirst(),"usr_id")==0?fallback:id(logs.getFirst(),"usr_id"); + } + /** First remember the MyISAM row id, then remove the temporary recovery marker. */ + void publish(long id){ + support.transactional("genericdatacontainer"); + db.writeTransaction(()->{ + var rows=db.lockRows("SELECT gdc_content,gdc_gen_fieldname FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngcsrpt' AND gdc_context='PENDING'",id);if(rows.isEmpty())return null; + Report r;try{r=support.json.readValue(text(rows.getFirst(),"gdc_content"),Report.class);}catch(Exception e){throw new IllegalStateException(e);} + String marker="[NG-Kundentyp:"+text(rows.getFirst(),"gdc_gen_fieldname")+"]"; + var found=db.queryForList("SELECT rp_id FROM `phoenix_group`.report_process WHERE rp_objtype='cs' AND rp_objid=? AND hq_id=? AND LOCATE(?,rp_text)>0 LIMIT 1",r.customer(),r.headquarters(),marker); + long report=found.isEmpty()?db.insert("`phoenix_group`.report_process",fields("hq_id",r.headquarters(),"rp_objtype","cs","rp_objid",r.customer(),"usr_id",r.user(),"rp_reporttype",r.type(),"rp_text",r.text()+"\n"+marker,"rp_confidential",0,"rp_createtime",LocalDateTime.parse(r.at()))):id(found.getFirst(),"rp_id"); + db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?","DELIVERED:"+report,id);return null; + }); + db.writeTransaction(()->{ + var rows=db.lockRows("SELECT gdc_content,gdc_gen_fieldname,gdc_context FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngcsrpt' AND gdc_context LIKE 'DELIVERED:%'",id);if(rows.isEmpty())return null; + var row=rows.getFirst();long report=Long.parseLong(text(row,"gdc_context").substring(10));Report r;try{r=support.json.readValue(text(row,"gdc_content"),Report.class);}catch(Exception e){throw new IllegalStateException(e);} + String original=r.text()+"\n[NG-Kundentyp:"+text(row,"gdc_gen_fieldname")+"]"; + // Do not overwrite manual edits or recreate a report removed after delivery. + db.update("UPDATE `phoenix_group`.report_process SET rp_text=? WHERE rp_id=? AND rp_objid=? AND hq_id=? AND BINARY rp_text=BINARY ?",r.text(),report,r.customer(),r.headquarters(),original); + db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=?",id);return null; + }); + } + void publishRequest(long customer,String token){ + try{for(var r:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngcsrpt' AND gdc_obj_id=? AND gdc_gen_fieldname=? AND gdc_context<>'SENT'",customer,token))publish(id(r,"gdc_id"));}catch(RuntimeException ignored){/* Saved pending report is visible in the customer editor and retried by the worker. */} + } + @Scheduled(initialDelay=60000,fixedDelay=30000) + public void publishPending(){ + try{for(var r:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngcsrpt' AND gdc_context<>'SENT' ORDER BY gdc_id LIMIT 50"))try{publish(id(r,"gdc_id"));}catch(RuntimeException ignored){/* Retain failed entry; do not starve following reports. */}}catch(RuntimeException ignored){} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/DataTransfer.java b/backend/src/main/java/de/stadtbote/votian/DataTransfer.java new file mode 100644 index 0000000..4b806e4 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/DataTransfer.java @@ -0,0 +1,31 @@ +package de.stadtbote.votian; + +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import java.util.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.AdminSupport.*; + +/** Central entry point for PHP data_transfer.php's local document tree. */ +@RestController +@RequestMapping("/api/data-transfer") +class DataTransfer { + final MasterDocuments documents; + DataTransfer(MasterDocuments documents){this.documents=documents;} + boolean allowed(LegacyUser u,MasterDocuments.Kind kind){return kind.right>=0&&u.hasRight(kind.right)&&EmployeeAdministration.bit(LegacyMapping.text(one(documents.db.queryForList("SELECT emp_rights FROM employee WHERE emp_id=? AND usr_id=?",u.employeeId(),u.userId())),"emp_rights"),kind.right);} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + Map options(LegacyUser u){boolean canExport=false;if(u.hasRight(6)){new MasterExports(documents.db,null).rights(u);canExport=true;}if(!u.hasRight(22)&&!u.hasRight(23)){if(!canExport)throw MasterDirectoryScope.denied();return fields("headquarters",u.headquartersId(),"canExport",true,"canDocuments",false,"canUpload",false,"canDownload",false,"kinds",List.of());}var c=documents.context(u,MasterDocuments.Kind.general,u.headquartersId());return fields("headquarters",u.headquartersId(),"canExport",canExport,"canDocuments",true,"canUpload",c.upload(),"canDownload",c.download(),"kinds",Arrays.stream(MasterDocuments.Kind.values()).filter(k->k!=MasterDocuments.Kind.general&&k!=MasterDocuments.Kind.transfer&&allowed(u,k)).map(k->fields("id",k.name(),"label",switch(k){case customers->"Kunden";case couriers->"Transporteure";case vehicles->"Fahrzeuge";case orders->"Aufträge";case employees->"Mitarbeiter";default->"Allgemeine Dokumente";})).toList());} + @GetMapping("/objects") Object objects(Authentication a,@RequestParam MasterDocuments.Kind kind,@RequestParam(defaultValue="")String search){return objects(ApiController.user(a),kind,search);} + Map objects(LegacyUser u,MasterDocuments.Kind kind,String search){documents.context(u,MasterDocuments.Kind.general,u.headquartersId());if(kind==null||kind==MasterDocuments.Kind.general)throw bad("Bitte einen Dokumentbereich auswählen.");if(!allowed(u,kind))throw MasterDirectoryScope.denied();if(search==null||search.length()>100)throw bad("Suchtext ist zu lang."); + String sql=switch(kind){ + case customers->"SELECT cs.cs_id AS id,CONCAT_WS(' · ',cs.cs_eid,c.cmp_comp) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=?"; + case couriers->"SELECT cr.cr_id AS id,CONCAT_WS(' · ',cr.cr_eid,c.cmp_comp) AS label FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.hq_id=?"; + case vehicles->"SELECT v.crvh_id AS id,CONCAT_WS(' · ',v.crvh_sid,v.crvh_vh_sign) AS label FROM couriervehicle v JOIN courier cr ON cr.cr_id=v.cr_id WHERE cr.hq_id=?"; + case employees->"SELECT e.emp_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE u.hq_id=? AND u.usr_type=1"; + case orders->"SELECT jb_id AS id,CONCAT_WS(' · ',CAST(jb_id AS CHAR),CAST(jb_ordertime AS CHAR),jb_orderer) AS label FROM job WHERE hq_id=?"; + default->throw bad("Ungültiger Dokumentbereich."); + }; + String match="%"+search.strip().replace("!","!!").replace("%","!%").replace("_","!_")+"%";boolean exact=kind==MasterDocuments.Kind.orders&&search.strip().matches("[0-9]+");var args=new ArrayList();args.add(u.headquartersId());if(!exact)args.add(match);args.add(search.strip()); + var rows=documents.db.queryForList("SELECT * FROM ("+sql+") docs WHERE "+(exact?"CAST(id AS CHAR)=?":"label LIKE ? ESCAPE '!' OR CAST(id AS CHAR)=?")+" ORDER BY "+(kind==MasterDocuments.Kind.orders?"id DESC":"label,id")+" LIMIT 101",args.toArray());return fields("items",rows.stream().limit(100).toList(),"truncated",rows.size()>100); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java b/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java new file mode 100644 index 0000000..e019c64 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/DispositionJobCards.java @@ -0,0 +1,70 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.*; +import java.text.DecimalFormat; +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 { + final DatabaseService db; + DispositionJobCards(DatabaseService db){this.db=db;} + record Settings(int mode,boolean zip,boolean verticalId,boolean related,List delivery,List installation,Map colours){} + record Card(long id,List lines,List details,String zipcode,boolean verticalId,String colour,String category){} + String parameter(long hq,String key){var c=new OrderEntryCatalog(db);String value=c.parameter(hq,key);return value.isEmpty()?c.parameter(0,key):value;} + String objectParameter(long hq,long customer,String key){String value=customer>0?parameter(hq,key+"_"+customer):"";return value.isEmpty()?parameter(hq,key):value;} + Settings settings(long hq,long customer){ + String mode=parameter(hq,"MASK_DISPOSITION_ITEM_DISPLAY_MODE"); + return new Settings(mode.equals("1")?1:mode.equals("2")?2:0,parameter(hq,"MASK_DISPOSITION_ITEM_SHOW_ZIPCODE").equals("1"),parameter(hq,"MASK_DISPOSITION_ITEM_DRAGBAR_SHOW_JB_ID").equals("1"),new OrderEntryCatalog(db).enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"),bits(objectParameter(hq,customer,"DISPOSITION_DELIVERYSERVICES")),bits(objectParameter(hq,customer,"DISPOSITION_INSTALLATIONSERVICES")),colours(objectParameter(hq,customer,"MASK_DISPOSITION_SERVICECOLOURS"))); + } + static List bits(String input){ + if(input.isBlank())return List.of();var out=new ArrayList(); + for(String part:input.split(",",-1)){String s=part.strip();if(s.isEmpty())continue;if(!s.matches("[0-9]{1,2}")||Integer.parseInt(s)>62)throw bad("Ungültige Dienstleistungsreihenfolge für die Dispositionsfarben.");out.add(Integer.parseInt(s));if(out.size()>63)throw bad("Zu viele Dienstleistungsfarben.");}return List.copyOf(out); + } + static Map colours(String input){ + if(input.isBlank())return Map.of();var out=new LinkedHashMap(); + for(String part:input.split("\\|",-1)){if(part.isBlank())continue;String[] pair=part.split("=",-1);if(pair.length!=2||!pair[0].strip().matches("[0-9]{1,2}")||Integer.parseInt(pair[0].strip())>62||!pair[1].strip().matches("#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?"))throw bad("Dispositionsfarben benötigen eindeutige Dienstleistungsnummern und Farben wie #88BB88.");int bit=Integer.parseInt(pair[0].strip());if(out.put(bit,pair[1].strip())!=null)throw bad("Mehrdeutige Dispositionsfarbe.");}return Map.copyOf(out); + } + static String formatted(BigDecimal value,int places){var f=new DecimalFormat("#,##0"+(places>0?"."+"0".repeat(places):""),DecimalFormatSymbols.getInstance(Locale.GERMANY));f.setRoundingMode(RoundingMode.HALF_UP);return f.format(value);} + static String joined(String a,String b){return (a+" "+b).strip();} + static String afterDate(String value){String s=value.strip();return s.codePointCount(0,s.length())<=12?"":s.substring(s.offsetByCodePoints(0,12));} + static String category(Map job,Settings settings){ + if(id(job,"jb_offer")==1||!settings.related()&&!text(job,"is_operative").isEmpty())return "Angebot"; + if(text(job,"jb_service_job").equals("1"))return "Serviceauftrag"; + return (id(job,"jb_service")&2)==2?"Montage":"Lieferung"; + } + static String colour(Map job,Settings s){ + String category=category(job,s);if(category.equals("Angebot"))return "#A349A4"; + if(category.equals("Serviceauftrag"))return BillingService.nz(decimal(job,"jb_totalprice")).signum()==0&&BillingService.nz(decimal(job,"jb_cr_price")).signum()==0?"#CC8000":"#FF8000"; + boolean installation=(id(job,"jb_service")&2)==2; + for(int bit:installation?s.installation():s.delivery())if((id(job,"jb_service")&(1L< j,Map stop,BigDecimal weight,BigDecimal pieces,Settings s){ + String company=text(stop,"tr_comp"),company2=text(stop,"tr_comp2"),person=text(stop,"tr_person"),area=text(stop,"tr_mediationarea_name"),street=joined(text(stop,"ad_street"),text(stop,"tr_hsno")),place=joined(text(stop,"ad_zipcode"),text(stop,"ad_city")),phone=text(stop,"tr_phone"),floor=text(stop,"tr_floor"),window=s.mode()==2?text(j,"timewindow_name"):afterDate(text(j,"jb_freetext_1")); + BigDecimal tax=decimal(j,"tx_value"),gross=BillingService.nz(decimal(j,"jb_totalprice")).multiply(BigDecimal.ONE.add(tax==null?new BigDecimal("0.19"):tax.movePointLeft(2))); + String price=formatted(gross,2)+" €",duration=id(j,"jb_timeunits")+" ZE";List lines; + if(s.mode()==1)lines=List.of(company,area,company2,price,formatted(weight,2)+" kg"); + else if(s.mode()==2)lines=List.of(window,company,person,street,place,formatted(weight,0)+" kg / "+pieces.stripTrailingZeros().toPlainString()+" Stk."); + else lines=List.of(window,street,place,duration,price,formatted(weight,3)+" kg"); + var full=new ArrayList();if(s.mode()!=1&&!window.isBlank())full.add("Zusage: "+window);full.add(company);if(!company2.isBlank())full.add(company2); + if(s.mode()==1)full.add(area);else {if(s.mode()==2)full.add(person);full.add(street);if(!floor.isBlank()&&!floor.equals("0"))full.add("Etage: "+floor);} + full.add(place);full.add(phone);if(s.mode()!=2)full.add(duration); + return new Card(id(j,"jb_id"),lines,full.stream().filter(v->!v.isBlank()).toList(),s.zip()&&s.mode()!=1?text(stop,"ad_zipcode"):"",s.verticalId(),colour(j,s),category(j,s)); + } + List read(long hq,long customer,Collection authorized){ + var ids=authorized.stream().distinct().sorted().toList();if(ids.isEmpty())return List.of();if(ids.size()>5000||ids.stream().anyMatch(i->i<=0))throw bad("Zu viele Aufträge für die Dispositionskarten."); + var settings=settings(hq,customer);var result=new ArrayList(); + for(int offset=0;offsetid(v,"jb_id")==id).toList();if(destinations.size()>1)throw bad("Eine zweite Station ist mehrdeutig. Bitte die Auftragsdaten prüfen.");var row=new LinkedHashMap<>(job);var seen=new HashSet();for(var meta:metadata)if(id(meta,"jb_id")==id){String key=text(meta,"gdc_gen_fieldname");if(!seen.add(key))throw bad("Mehrdeutige Zusatzangabe für die Dispositionskarte.");row.put(key,text(meta,"gdc_content"));}var total=totals.stream().filter(v->id(v,"jb_id")==id).findFirst().orElse(Map.of());result.add(format(row,destinations.isEmpty()?Map.of():destinations.getFirst(),BillingService.nz(decimal(total,"weight")),BillingService.nz(decimal(total,"pieces")),settings));} + }return List.copyOf(result); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/DispositionTimelineOptions.java b/backend/src/main/java/de/stadtbote/votian/DispositionTimelineOptions.java new file mode 100644 index 0000000..c3094d1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/DispositionTimelineOptions.java @@ -0,0 +1,18 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.Set; +import static de.stadtbote.votian.AdminSupport.*; + +/** Presentation preferences from the selected branch, with the PHP global fallback. */ +final class DispositionTimelineOptions { + private final DispositionJobCards parameters; + DispositionTimelineOptions(DatabaseService db){parameters=new DispositionJobCards(db);} + boolean confirmDate(long headquarters){return parameters.parameter(headquarters,"MASK_DISPOSITION_CALENDER_SUBMIT_DISABLED").equals("1");} + boolean allowPastDrops(long headquarters){return parameters.parameter(headquarters,"MASK_DISPOSITION_TIMELINE_ENABLE_DROPPING_ON_PAST_TIMESLOTS").equals("1");} + String companyColumn(long loginHeadquarters){ + String suffix=parameters.parameter(loginHeadquarters,"MASK_DISPOSITION_CMP_FIELD_DISPLAYED"); + if(!Set.of("","2","3","4").contains(suffix))throw bad("Ungültige Auswahl des Firmennamens für die Fahrzeugdisposition."); + return "cmp.cmp_comp"+suffix; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/DispositionTimelineRange.java b/backend/src/main/java/de/stadtbote/votian/DispositionTimelineRange.java new file mode 100644 index 0000000..b1eff9e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/DispositionTimelineRange.java @@ -0,0 +1,30 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Display range only: stored calendars and planning validation always retain all 24 hours. */ +final class DispositionTimelineRange { + final DatabaseService db; + DispositionTimelineRange(DatabaseService db){this.db=db;} + record Range(int startHour,int endHourExclusive,boolean configured,String warning){} + static int number(String value,int fallback,int min,int max){try{int n=new BigDecimal(value.strip()).intValueExact();return n>=min&&n<=max?n:fallback;}catch(NumberFormatException|ArithmeticException e){return fallback;}} + Range read(long loginHeadquarters,long customer){ + var parameters=new OrderEntryCatalog(db); + int last=number(new OrderEntryCatalog(db).parameter(0,"DISPOSITION_HOURS"),23,0,23); + var rows=customer>0?db.queryForList("SELECT mt_sort,mt_mapped_value FROM metatype WHERE mt_type='day_time' AND mt_objtype='cs' AND mt_objid=? ORDER BY mt_sort LIMIT 101",customer):List.>of(); + return range(rows,last,offset(parameters,loginHeadquarters,"DISPOSITION_START_HOUR_OFFSET"),offset(parameters,loginHeadquarters,"DISPOSITION_END_HOUR_OFFSET")); + } + static int offset(OrderEntryCatalog parameters,long hq,String key){return number(parameters.parameter(hq,key),number(parameters.parameter(0,key),0,-24,24),-24,24);} + static Range range(List> rows,int last,int startOffset,int endOffset){ + if(rows.isEmpty())return new Range(0,last+1,last!=23,""); + boolean malformed=rows.size()>100||rows.stream().map(r->id(r,"mt_sort")).distinct().count()!=rows.size(); + for(var row:rows){String value=text(row,"mt_mapped_value");if(!value.matches("(?:[01][0-9]|2[0-3]):[0-5][0-9] - (?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)")){malformed=true;break;}int from=Integer.parseInt(value.substring(0,2))*60+Integer.parseInt(value.substring(3,5)),to=Integer.parseInt(value.substring(8,10))*60+Integer.parseInt(value.substring(11,13));if(to<=from)malformed=true;} + if(malformed)return new Range(0,last+1,last!=23,"Die Kundentageszeiten sind nicht eindeutig oder ungültig. Angezeigt wird der gesamte konfigurierte Tag."); + int from=Math.max(0,Integer.parseInt(text(rows.getFirst(),"mt_mapped_value").substring(0,2))+startOffset),to=Math.min(last,Integer.parseInt(text(rows.getLast(),"mt_mapped_value").substring(8,10))+endOffset); + if(from>to||from>23||to<0)return new Range(0,last+1,last!=23,"Die Anzeigeverschiebung ergibt keinen gültigen Zeitraum. Angezeigt wird der gesamte konfigurierte Tag."); + return new Range(from,to+1,from!=0||to!=23,""); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/DocumentStore.java b/backend/src/main/java/de/stadtbote/votian/DocumentStore.java new file mode 100644 index 0000000..5c679cf --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/DocumentStore.java @@ -0,0 +1,74 @@ +package de.stadtbote.votian; + +import com.jcraft.jsch.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; + +/** Shared PHP document tree, locally mounted or reached by host-key-checked SFTP. */ +@Component +class DocumentStore { + record Entry(String name,boolean directory,long size,long modified){} + interface Connection extends AutoCloseable { + List list(String path); + byte[] read(String path,int maximum); + void create(String path,byte[] data); + void move(String from,String to); + void mkdir(String path); + void remove(String path); + boolean exists(String path); + void close(); + } + final String mode,root,host,user,password,key,knownHosts;final int port; + DocumentStore(@Value("${app.documents.mode:local}")String mode,@Value("${app.documents.root:}")String root, + @Value("${app.documents.host:}")String host,@Value("${app.documents.port:22}")int port, + @Value("${app.documents.user:}")String user,@Value("${app.documents.password:}")String password, + @Value("${app.documents.key:}")String key,@Value("${app.documents.known-hosts:}")String knownHosts){ + this.mode=mode;this.root=root;this.host=host;this.port=port;this.user=user;this.password=password;this.key=key;this.knownHosts=knownHosts; + } + boolean configured(){return !root.isBlank()&&(mode.equals("local")||mode.equals("sftp")&&!host.isBlank()&&!user.isBlank()&&!knownHosts.isBlank()&&(!password.isBlank()||!key.isBlank()));} + static String component(String name){if(name==null||name.isBlank()||name.length()>240||name.equals(".")||name.equals("..")||name.startsWith(".")||name.chars().anyMatch(c->Character.isISOControl(c)||"/\\*?[]".indexOf(c)>=0))throw bad("Ungültiger Datei- oder Verzeichnisname.");return name;} + static String path(String path){if(path==null||path.length()>1500||path.startsWith("/")||path.endsWith("/"))throw bad("Ungültiger Dokumentpfad.");if(path.isEmpty())return path;for(String part:path.split("/",-1)){if(part.equals(".ng-staging")||part.equals(".ng-trash"))continue;component(part);}return path;} + static String digest(byte[] bytes){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception e){throw new IllegalStateException(e);}} + static RuntimeException unavailable(){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Dokumentablage nicht erreichbar. Bitte Verbindung und Ablagekonfiguration prüfen; offene Vorgänge können wiederholt werden.");} + Connection open(){if(!configured())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Die gemeinsame Dokumentablage ist noch nicht konfiguriert.");return mode.equals("local")?new Local(Path.of(root)):new Remote();} + static final class Local implements Connection { + final Path root; + Local(Path root){try{this.root=root.toRealPath();if(!Files.isDirectory(this.root,LinkOption.NOFOLLOW_LINKS))throw unavailable();}catch(IOException e){throw unavailable();}} + Path resolve(String relative){path(relative);Path target=root;for(String part:relative.isEmpty()?List.of():List.of(relative.split("/"))){target=target.resolve(part);if(Files.isSymbolicLink(target))throw bad("Symbolische Verknüpfungen sind in der Dokumentablage nicht zugelassen.");}return target;} + public List list(String relative){try(var stream=Files.list(resolve(relative))){var result=new ArrayList();for(Path p:stream.limit(10002).toList()){if(Files.isSymbolicLink(p))continue;var a=Files.readAttributes(p,BasicFileAttributes.class,LinkOption.NOFOLLOW_LINKS);if(a.isRegularFile()||a.isDirectory())result.add(new Entry(p.getFileName().toString(),a.isDirectory(),a.size(),a.lastModifiedTime().toMillis()/1000));}if(result.size()>10000)throw bad("Zu viele Einträge in der Dokumentablage.");return result;}catch(IOException e){throw unavailable();}} + public byte[] read(String relative,int maximum){try{Path p=resolve(relative);if(!Files.isRegularFile(p,LinkOption.NOFOLLOW_LINKS))throw bad("Dokument nicht gefunden.");if(Files.size(p)>maximum)throw bad("Dokument überschreitet die erlaubte Größe.");try(var input=Files.newInputStream(p,LinkOption.NOFOLLOW_LINKS)){byte[] bytes=input.readNBytes(maximum+1);if(bytes.length>maximum)throw bad("Dokument überschreitet die erlaubte Größe.");return bytes;}}catch(IOException e){throw unavailable();}} + public void create(String relative,byte[] bytes){try{Files.write(resolve(relative),bytes,StandardOpenOption.CREATE_NEW,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);}catch(FileAlreadyExistsException e){throw bad("Die Datei existiert bereits.");}catch(IOException e){throw unavailable();}} + public void move(String from,String to){try{if(exists(to))throw bad("Der Zieldateiname ist inzwischen belegt.");Files.createLink(resolve(to),resolve(from));Files.delete(resolve(from));}catch(IOException e){throw unavailable();}} + public void remove(String relative){try{Files.delete(resolve(relative));}catch(IOException e){throw unavailable();}} + public void mkdir(String relative){try{Files.createDirectories(resolve(relative));}catch(IOException e){throw unavailable();}} + public boolean exists(String relative){return Files.exists(resolve(relative),LinkOption.NOFOLLOW_LINKS);} + public void close(){} + } + final class Remote implements Connection { + 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); + 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; + }catch(Exception e){if(channel!=null)channel.disconnect();if(sess!=null)sess.disconnect();throw unavailable();}} + String resolve(String relative){path(relative);String current=base;try{for(String part:relative.isEmpty()?List.of():List.of(relative.split("/"))){current+="/"+part;try{if(sftp.lstat(current).isLink())throw bad("Symbolische Verknüpfungen sind in der Dokumentablage nicht zugelassen.");}catch(SftpException e){if(e.id!=ChannelSftp.SSH_FX_NO_SUCH_FILE)throw e;}}return current;}catch(SftpException e){throw unavailable();}} + public List list(String relative){try{var out=new ArrayList();sftp.ls(resolve(relative),e->{String n=e.getFilename();var a=e.getAttrs();if(!n.equals(".")&&!n.equals("..")&&!a.isLink()&&(a.isDir()||a.isReg()))out.add(new Entry(n,a.isDir(),a.getSize(),Integer.toUnsignedLong(a.getMTime())));return out.size()>10000?ChannelSftp.LsEntrySelector.BREAK:ChannelSftp.LsEntrySelector.CONTINUE;});if(out.size()>10000)throw bad("Mehr als 10.000 Einträge in der Dokumentablage. Bitte das Verzeichnis bereinigen.");return out;}catch(SftpException e){throw unavailable();}} + public byte[] read(String relative,int maximum){try{String p=resolve(relative);var a=sftp.lstat(p);if(!a.isReg())throw bad("Dokument nicht gefunden.");if(a.getSize()>maximum)throw bad("Dokument überschreitet die erlaubte Größe.");try(var in=sftp.get(p)){byte[] bytes=in.readNBytes(maximum+1);if(bytes.length>maximum)throw bad("Dokument überschreitet die erlaubte Größe.");return bytes;}}catch(SftpException|IOException e){throw unavailable();}} + public void create(String relative,byte[] bytes){try{if(exists(relative))throw bad("Die Datei existiert bereits.");sftp.put(new ByteArrayInputStream(bytes),resolve(relative));}catch(SftpException e){throw unavailable();}} + public void move(String from,String to){try{if(exists(to))throw bad("Der Zieldateiname ist inzwischen belegt.");sftp.hardlink(resolve(from),resolve(to));sftp.rm(resolve(from));}catch(SftpException e){throw unavailable();}} + public void remove(String relative){try{sftp.rm(resolve(relative));}catch(SftpException e){throw unavailable();}} + public void mkdir(String relative){path(relative);String current="";try{for(String part:relative.split("/")){current+=current.isEmpty()?part:"/"+part;String absolute=resolve(current);try{if(!sftp.lstat(absolute).isDir())throw bad("Ein Verzeichnisname ist durch eine Datei belegt.");}catch(SftpException e){if(e.id!=ChannelSftp.SSH_FX_NO_SUCH_FILE)throw e;sftp.mkdir(absolute);}}}catch(SftpException e){throw unavailable();}} + public boolean exists(String relative){try{sftp.lstat(resolve(relative));return true;}catch(SftpException e){if(e.id==ChannelSftp.SSH_FX_NO_SUCH_FILE)return false;throw unavailable();}} + public void close(){sftp.disconnect();session.disconnect();} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeAccounts.java b/backend/src/main/java/de/stadtbote/votian/EmployeeAccounts.java new file mode 100644 index 0000000..60ac0de --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeAccounts.java @@ -0,0 +1,87 @@ +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.LocalDate; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Account lifecycle; passwords are never part of API responses, receipts or audit data. */ +@RestController +@RequestMapping("/api/administration/employees") +class EmployeeAccounts { + final DatabaseService db; final BusinessSupport support; final EmployeeAdministration employees; + EmployeeAccounts(DatabaseService db,BusinessSupport support,EmployeeAdministration employees){this.db=db;this.support=support;this.employees=employees;} + record Create(String requestId,long headquarters,String name,String firstName,String email,String phone,String mobile,String account,LocalDate birthdate,String password,String confirmation){@Override public String toString(){return "Create[redacted]";}} + record Password(String requestId,String revision,String password,String confirmation,String currentPassword){@Override public String toString(){return "Password[redacted]";}} + record Delete(String requestId,String revision,String account){} + record State(EmployeeAdministration.Snapshot employee,Map credentials){} + static final Map DEFAULTS=Map.of( + "MASK_CS_LIST_COLS","cmp_comp,cmp_comp2,ad_street,ad_zipcode,cmp_match,cs_eid,usr_phone,usr_email,csc_id_jb_edit,cs_id_history,cs_id_report,cmp_authenticated_img,cs_id_jb_permanent,cmp_id_edit", + "MASK_CR_LIST_COLS","cr_sid,crvh_vh_sign,cr_eid,crvh_prov,usr_name,usr_firstname,usr_phone,cr_mobile_pda,cmp_authenticated_img,cr_id_history,cr_id_report,cmp_id_edit,ad_zipcode,cr_serialno", + "MASK_JB_LIST_COLS","jb_id,business_volume,jb_markup,jb_cr_price,jb_cr_subprice,jb_serviceprice,jb_bsg,jb_tourname,tr_comp,streethsno,jb_tourzipcodes,vht_value,jb_taketime,jb_ordertime,jb_warningtime,jb_map,jb_tr_sort,jb_reserv,jb_permanent,cr_id_favoured,jb_csc_id,cr_sid", + "MASK_CSCSC_LIST_COLS","cmp_comp,cmp_comp2,cs_eid,csc_name,cmp_match,cmp_authenticated_img,ad_street,cscad_hsno,ad_zipcode,ad_city,cscad_remark,cscad_person,cscad_phone,statistic"); + @PostMapping Object create(Authentication a,@RequestBody Create r){return create(ApiController.user(a),r);} + @GetMapping("/{employee}/access") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);} + @PutMapping("/{employee}/password") Object password(Authentication a,@PathVariable long employee,@RequestBody Password r){return password(ApiController.user(a),employee,r);} + @PostMapping("/{employee}/delete") Object delete(Authentication a,@PathVariable long employee,@RequestBody Delete r){return delete(ApiController.user(a),employee,r);} + State state(LegacyUser u,long employee){return state(u,employee,false);} + State state(LegacyUser u,long employee,boolean self){if(self&&employee!=u.employeeId())throw EmployeeAdministration.denied();var s=self?employees.selfSnapshot(u):employees.snapshot(u,employee);return new State(s,one(db.queryForList("SELECT "+LegacyCredentialState.EXPRESSION+" AS credential_stamp,usr_password_modify,usr_totp_activated FROM user WHERE usr_id=?",id(s.person(),"usr_id"))));} + boolean canPassword(LegacyUser u,State s){return id(s.employee().person(),"emp_id")==u.employeeId()||!s.employee().protectedAccount()&&(s.employee().access().root()||EmployeeAdministration.bit(text(s.employee().access().actor(),"emp_rights"),37));} + Map view(LegacyUser u,long employee){return view(u,employee,false);} + Map view(LegacyUser u,long employee,boolean self){return db.readTransaction(()->{var s=state(u,employee,self);return fields("profileRevision",revision(s.employee()),"profile",EmployeeAdministration.profile(s.employee().person()),"revision",revision(s),"self",employee==u.employeeId(),"canPassword",canPassword(u,s),"canDelete",employee!=u.employeeId()&&!s.employee().protectedAccount(),"twoFactorActive",id(s.credentials(),"usr_totp_activated")==1,"passwordChanged",text(s.credentials(),"usr_password_modify"));});} + static void validatePassword(String password,String confirmation){ + if(password==null||password.length()<8||password.length()>128||!password.matches("(?s).*[A-Za-z].*")||!password.matches("(?s).*[0-9].*")||password.chars().anyMatch(Character::isISOControl))throw bad("Das Passwort muss 8 bis 128 Zeichen sowie mindestens einen Buchstaben und eine Ziffer enthalten."); + if(!password.equals(confirmation))throw bad("Die Passwörter stimmen nicht überein."); + } + Map create(LegacyUser u,Create r){ + u.require(3);if(r==null)throw bad("Mitarbeiterdaten fehlen.");BusinessSupport.token(r.requestId());validatePassword(r.password(),r.confirmation()); + String name=required(r.name(),50,"Name"),first=required(r.firstName(),50,"Vorname"),account=required(r.account(),20,"Anmeldename"); + String email=EmployeeAdministration.optional(r.email(),250,"E-Mail"),phone=EmployeeAdministration.optional(r.phone(),20,"Telefon"),mobile=EmployeeAdministration.optional(r.mobile(),20,"Mobil"); + if(!email.isEmpty()&&!email.matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+"))throw bad("Bitte eine gültige E-Mail-Adresse eingeben."); + if(r.birthdate()!=null&&(r.birthdate().isBefore(LocalDate.of(1900,1,1))||r.birthdate().isAfter(LocalDate.now())))throw bad("Bitte einen gültigen Geburtstag eingeben."); + var profile=fields("name",name,"firstName",first,"email",email,"phone",phone,"mobile",mobile,"account",account,"birthdate",r.birthdate()); + Object intent=List.of("EMPLOYEE_CREATE",r.headquarters(),profile); + support.transactional("user","employee","parameter","genericdatacontainer"); + return db.employeeWriteTransaction(()->{ + var access=employees.access(u);if(access.branches().stream().noneMatch(b->id(b,"id")==r.headquarters()))throw EmployeeAdministration.denied(); + Long previous=support.receipt(u,r.requestId(),intent); + if(previous!=null){var p=one(db.queryForList(EmployeeAdministration.PERSON_SQL+" AND employee.emp_id=?",previous));if(id(p,"hq_id")!=r.headquarters()||!matches(id(p,"usr_id"),r.password(),false))throw bad("Der Vorgang ist bereits abgeschlossen; die Zugangsdaten wurden inzwischen geändert oder weichen ab.");return fields("id",previous);} + if(!db.queryForList("SELECT usr_id FROM user WHERE usr_account=?",account).isEmpty())throw bad("Der Anmeldename wird bereits verwendet."); + long md=id(one(db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=?",r.headquarters())),"md_id"); + long uid=db.insert("user",fields("hq_id",r.headquarters(),"usr_type",1,"usr_name",name,"usr_firstname",first,"usr_email",email,"usr_phone",phone,"usr_phone2",mobile,"usr_account",account,"usr_birthdate",r.birthdate(),"usr_country","","usr_inv_email","","usr_reminder_email","","usr_password_old","","usr_totp_secret","","usr_totp_sessionkey","","usr_totp_activated",0)); + db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_modify=NOW() WHERE usr_id=?",r.password(),uid); + long employee=db.insert("employee",fields("usr_id",uid,"csc_id",0,"emp_rights","0".repeat(70),"emp_heritable_rights","0".repeat(70),"emp_headquarters",Long.toString(r.headquarters()))); + DEFAULTS.forEach((key,value)->db.insert("parameter",fields("par_key",key,"par_value",value,"emp_id",employee,"hq_id",r.headquarters(),"md_id",md))); + MetaObjectRegistration.enqueue(db,support,r.headquarters(),"usr",uid); + support.auditEmployee(u,employee,73,"EMPLOYEE="+employee+" | HQ="+r.headquarters()+" | PROFILE="+support.encode(profile),r.requestId());support.receipt(u,r.requestId(),intent,employee);return fields("id",employee); + }); + } + boolean matches(long uid,String password,boolean legacy){if(password==null||password.length()>1024)return false;return !db.queryForList("SELECT usr_id FROM user WHERE usr_id=? AND (usr_password=PASSWORD(?)"+(legacy?" OR usr_password_old=OLD_PASSWORD(?)":"")+")",legacy?new Object[]{uid,password,password}:new Object[]{uid,password}).isEmpty();} + Map password(LegacyUser u,long employee,Password r){return password(u,employee,r,false);} + Map password(LegacyUser u,long employee,Password r,boolean self){ + if(self){if(employee!=u.employeeId())throw EmployeeAdministration.denied();}else u.require(3);if(r==null)throw bad("Passwortangaben fehlen.");BusinessSupport.token(r.requestId());validatePassword(r.password(),r.confirmation()); + Object intent=List.of("EMPLOYEE_PASSWORD",employee,Objects.toString(r.revision(),""));support.transactional("user","genericdatacontainer"); + return db.employeeWriteTransaction(()->{ + var s=state(u,employee,self);if(!canPassword(u,s))throw EmployeeAdministration.denied();long uid=id(s.employee().person(),"usr_id"); + Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null){if(!matches(uid,r.password(),false))throw bad("Der Vorgang ist bereits abgeschlossen; das Passwort wurde inzwischen geändert oder weicht ab.");return fields("id",previous,"reauthenticate",employee==u.employeeId());} + unchanged(s,r.revision());if(employee==u.employeeId()&&!matches(uid,r.currentPassword(),true))throw bad("Das aktuelle Passwort ist ungültig."); + db.update("UPDATE user SET usr_password=PASSWORD(?),usr_password_old='',usr_password_modify=NOW(),usr_totp_sessionkey=? WHERE usr_id=?",r.password(),UUID.randomUUID().toString().replace("-",""),uid); + support.auditEmployee(u,employee,76,"EMPLOYEE="+employee+" | PASSWORD_CHANGED | PREVIOUS_SESSIONS_REVOKED",r.requestId());support.receipt(u,r.requestId(),intent,employee);return fields("id",employee,"reauthenticate",employee==u.employeeId()); + }); + } + Map delete(LegacyUser u,long employee,Delete r){ + u.require(3);if(r==null)throw bad("Löschbestätigung fehlt.");BusinessSupport.token(r.requestId());Object intent=List.of("EMPLOYEE_DELETE",employee,Objects.toString(r.revision(),""),Objects.toString(r.account(),"")); + support.transactional("user","employee","employeerights","parameter","genericdatacontainer");return db.employeeWriteTransaction(()->{ + employees.access(u);Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous); + var s=state(u,employee);if(employee==u.employeeId()||s.employee().protectedAccount())throw EmployeeAdministration.denied();unchanged(s,r.revision()); + if(!text(s.employee().person(),"usr_account").equals(r.account()))throw bad("Bitte den Anmeldenamen zur Löschbestätigung eingeben.");long uid=id(s.employee().person(),"usr_id"); + if(db.queryForList("SELECT emp_id FROM employee WHERE usr_id=?",uid).size()!=1)throw bad("Das Benutzerkonto wird mehrfach verwendet und kann nicht gelöscht werden."); + db.update("DELETE FROM employeerights WHERE emp_id=?",employee);db.update("DELETE FROM parameter WHERE emp_id=?",employee);db.update("DELETE FROM employee WHERE emp_id=?",employee);db.update("DELETE FROM user WHERE usr_id=?",uid);MetaObjectRegistration.enqueueRemoval(db,support,id(s.employee().person(),"hq_id"),uid); + support.auditEmployee(u,employee,75,"EMPLOYEE="+employee+" | PROFILE_DELETED="+support.encode(EmployeeAdministration.profile(s.employee().person())),r.requestId());support.receipt(u,r.requestId(),intent,employee);return fields("id",employee); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeAdministration.java b/backend/src/main/java/de/stadtbote/votian/EmployeeAdministration.java new file mode 100644 index 0000000..749890f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeAdministration.java @@ -0,0 +1,164 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** HQ employees, their general/heritage vectors and the separate PHP HQ-right matrix. */ +@RestController +@RequestMapping("/api/administration/employees") +class EmployeeAdministration { + final DatabaseService db; final BusinessSupport support; + EmployeeAdministration(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + record Access(Map actor,List> branches,boolean root){} + record Snapshot(Access access,Map person,List> definitions, + List> matrix,List> actorMatrix,List> settings,boolean protectedAccount){} + record Cell(long headquarters,int right){} + record RightsSave(String requestId,String revision,List rights,List heritable,List headquarters,List matrix){} + record ProfileSave(String requestId,String revision,String name,String firstName,String email,String phone,String mobile,String account,LocalDate birthdate){} + static final String PERSON_SQL="SELECT employee.emp_id,employee.usr_id,employee.emp_rights,employee.emp_heritable_rights,employee.emp_headquarters,user.hq_id,user.usr_name,user.usr_firstname,user.usr_email,user.usr_phone,user.usr_phone2,user.usr_account,user.usr_birthdate FROM employee JOIN user ON user.usr_id=employee.usr_id WHERE employee.csc_id=0 AND user.usr_type=1"; + @GetMapping("/headquarters") Object headquarters(Authentication auth){return branches(ApiController.user(auth));} + @GetMapping Object list(Authentication auth,@RequestParam(required=false) Long headquarters,@RequestParam(required=false) List headquartersIds,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="name") String sort){return headquartersIds==null?list(ApiController.user(auth),headquarters,q,sort):listSelected(ApiController.user(auth),headquartersIds,q,sort);} + @GetMapping("/{employee}") Object get(Authentication auth,@PathVariable long employee){return view(ApiController.user(auth),employee);} + @PutMapping("/{employee}/rights") Object rights(Authentication auth,@PathVariable long employee,@RequestBody RightsSave request){return saveRights(ApiController.user(auth),employee,request);} + @PutMapping("/{employee}/profile") Object profile(Authentication auth,@PathVariable long employee,@RequestBody ProfileSave request){return saveProfile(ApiController.user(auth),employee,request);} + + Access access(LegacyUser u){return access(u,false);} + Access access(LegacyUser u,boolean selfOnly){ + if(!selfOnly)u.require(3); + var actor=new LinkedHashMap<>(one(db.queryForList(PERSON_SQL+" AND employee.emp_id=? AND user.usr_id=? AND user.hq_id=?",u.employeeId(),u.userId(),u.originalHeadquartersId()))); + String live=text(actor,"emp_rights");var effective=new StringBuilder(); + for(int i=0;iid(r,"id")).distinct().count()!=all.size())throw bad("Die Niederlassungszuordnung ist nicht eindeutig."); + var own=one(all.stream().filter(r->id(r,"id")==u.originalHeadquartersId()).toList());boolean root=id(own,"administrator")==u.employeeId(); + var granted=ids(text(actor,"emp_headquarters")); + var branches=all.stream().filter(r->id(r,"id")==u.originalHeadquartersId()||!selfOnly&&u.hasRight(10)&&bit(text(actor,"emp_rights"),10)&&(root||granted.contains(id(r,"id")))).toList(); + for(var branch:branches)if(db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=?",id(branch,"id")).size()!=1)throw denied(); + return new Access(actor,branches,root); + } + Map branches(LegacyUser u){return db.readTransaction(()->fields("currentId",u.originalHeadquartersId(),"items",access(u).branches().stream().map(b->fields("id",id(b,"id"),"label",text(b,"label"))).toList()));} + Map list(LegacyUser u,Long selected,String search,String sort){return listSelected(u,selected!=null&&selected==0?null:List.of(selected==null?u.originalHeadquartersId():selected),search,sort);} + Map listSelected(LegacyUser u,List selected,String search,String sort){ + u.require(3);if(selected!=null&&(selected.size()>500||selected.stream().anyMatch(h->h==null||h<=0)||new HashSet<>(selected).size()!=selected.size()))throw bad("Ungültige Niederlassungsauswahl."); + return db.readTransaction(()->{ + var a=access(u);var ids=selected==null?a.branches().stream().map(b->id(b,"id")).toList():selected.isEmpty()?List.of(u.originalHeadquartersId()):selected; + var allowed=a.branches().stream().map(b->id(b,"id")).toList();if(!allowed.containsAll(ids))throw denied(); + var selectedBranches=a.branches().stream().filter(b->ids.contains(id(b,"id"))).toList();if(selectedBranches.isEmpty())throw denied(); + String column=switch(sort){case "name"->"user.usr_name,user.usr_firstname";case "firstName"->"user.usr_firstname,user.usr_name";case "account"->"user.usr_account";case "email"->"user.usr_email";case "phone"->"user.usr_phone";case "headquarters"->"(SELECT hq_mnemonic FROM headquarters WHERE headquarters.hq_id=user.hq_id),user.usr_name";default->throw bad("Ungültige Sortierung.");}; + String q=search==null?"":search.strip();if(q.length()>100)throw bad("Die Suche darf höchstens 100 Zeichen enthalten."); + var args=new ArrayList();selectedBranches.forEach(b->args.add(id(b,"id"))); + String sql=PERSON_SQL+" AND user.hq_id IN ("+String.join(",",Collections.nCopies(args.size(),"?"))+")"; + if(!q.isEmpty()){sql+=" AND (LOCATE(?,user.usr_name)>0 OR LOCATE(?,user.usr_firstname)>0 OR LOCATE(?,user.usr_account)>0 OR LOCATE(?,user.usr_email)>0 OR LOCATE(?,COALESCE(user.usr_phone,''))>0 OR LOCATE(?,COALESCE(user.usr_phone2,''))>0)";for(int i=0;i<6;i++)args.add(q);} + var rows=db.queryForList(sql+" ORDER BY "+column+",employee.emp_id LIMIT 501",args.toArray()); + var administrators=db.queryForList("SELECT hq_admin FROM headquarters WHERE hq_admin>0").stream().map(r->id(r,"hq_admin")).collect(java.util.stream.Collectors.toSet()); + return fields("items",rows.stream().limit(500).map(r->{var p=profile(r);p.put("canCallCenter",u.headquartersId()==u.originalHeadquartersId()&&bit(text(a.actor(),"emp_rights"),34));p.put("canPreferences",id(r,"emp_id")==u.employeeId()||a.root()||bit(text(a.actor(),"emp_rights"),50)&&!administrators.contains(id(r,"emp_id")));p.put("canHistory",EmployeeHistory.allowed(a,administrators.contains(id(r,"emp_id"))));p.put("headquartersLabel",a.branches().stream().filter(b->id(b,"id")==id(r,"hq_id")).map(b->text(b,"label")).findFirst().orElse(""));return p;}).toList(),"truncated",rows.size()>500,"headquartersIds",ids); + });} + Snapshot selfSnapshot(LegacyUser u){var a=access(u,true);return new Snapshot(a,a.actor(),List.of(),List.of(),List.of(),List.of(),!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",u.employeeId()).isEmpty());} + Snapshot snapshot(LegacyUser u,long employee){ + if(employee<=0)throw bad("Ungültiger Mitarbeiter.");var a=access(u); + var person=one(db.queryForList(PERSON_SQL+" AND employee.emp_id=?",employee)); + if(a.branches().stream().noneMatch(b->id(b,"id")==id(person,"hq_id")))throw denied(); + var definitions=db.queryForList("SELECT rgt_id,rgt_name,rgt_vip,rgt_emp_vip FROM rights ORDER BY rgt_id"); + // PHP indexes the ordered dictionary, not the SQL id. Reject gaps rather than shifting permissions. + if(definitions.isEmpty()||definitions.size()>70)throw bad("Die Rechtedefinitionen sind unvollständig."); + for(int i=0;itext(r,"par_key").equals(key)).count()>1)throw bad("Der Rechteparameter ist mehrfach vorhanden: "+key); + String length=setting(settings,"EMP_BITSTR_MAXLEN");if(!length.isEmpty()&&!length.equals("70"))throw bad("Die konfigurierte Länge der Rechte muss zur bestehenden 70-stelligen Spalte passen."); + var matrix=matrix(employee);var actorMatrix=employee==u.employeeId()?matrix:matrix(u.employeeId()); + boolean admin=!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",employee).isEmpty(); + return new Snapshot(a,person,definitions,matrix,actorMatrix,settings,admin); + } + List> matrix(long emp){ + return matrix(db,emp); + } + static List> matrix(DatabaseService db,long emp){ + var rows=db.queryForList("SELECT hq_id,rgt_id FROM employeerights WHERE emp_id=? ORDER BY hq_id,rgt_id",emp); + if(rows.stream().map(r->new Cell(id(r,"hq_id"),(int)id(r,"rgt_id"))).distinct().count()!=rows.size())throw bad("Die Niederlassungsrechte sind mehrfach vorhanden. Bitte zuerst bereinigen.");return rows; + } + Map view(LegacyUser u,long employee){return db.readTransaction(()->view(u,snapshot(u,employee)));} + Map view(LegacyUser u,Snapshot s){ + var p=s.person();boolean editable=id(p,"emp_id")!=u.employeeId()&&!s.protectedAccount(); + var rights=new ArrayList>();var matrix=new ArrayList>(); + for(int i=0;ifields("id",id(b,"id"),"label",text(b,"label"),"enabled",grants.contains(id(b,"id")),"canEdit",editable&&actorGrants.contains(id(b,"id")))).toList(),"matrix",matrix,"protectedAccount",s.protectedAccount(),"self",id(p,"emp_id")==u.employeeId()); + } + static Map profile(Map p){String birthday=text(p,"usr_birthdate");return fields("id",id(p,"emp_id"),"headquartersId",id(p,"hq_id"),"name",text(p,"usr_name"),"firstName",text(p,"usr_firstname"),"email",text(p,"usr_email"),"phone",text(p,"usr_phone"),"mobile",text(p,"usr_phone2"),"account",text(p,"usr_account"),"birthdate",birthday.startsWith("0000-")?"":birthday);} + static boolean bit(String s,int i){return i>=0&&i ids(String csv){var values=new LinkedHashSet();for(String item:csv.split(",")){if(item.isBlank())continue;try{long id=Long.parseLong(item.strip());if(id<=0)throw new NumberFormatException();values.add(id);}catch(NumberFormatException e){throw bad("Ungültige gespeicherte Niederlassungsfreigabe.");}}return values;} + static String setting(List> settings,String key){return settings.stream().filter(s->text(s,"par_key").equals(key)).map(s->text(s,"par_value")).findFirst().orElse("");} + static boolean heritage(Snapshot s){return !setting(s.settings(),"GLOBAL_RIGHTS_HERITAGE_MODE_DISABLED").equals("1");} + static boolean canRight(Snapshot s,int i){ + var actor=s.access().actor();if(heritage(s))return bit(text(actor,"emp_heritable_rights"),i)&&bit(text(actor,"emp_rights"),i); + var d=s.definitions().get(i);return bit(text(actor,"emp_rights"),i)&&(id(d,"rgt_vip")!=1||s.access().root()||Arrays.stream(text(d,"rgt_emp_vip").split(",")).anyMatch(v->v.strip().equals(Long.toString(id(actor,"emp_id"))))); + } + static boolean cell(List> matrix,long hq,int right){return matrix.stream().anyMatch(c->id(c,"hq_id")==hq&&id(c,"rgt_id")==right);} + static boolean canCell(Snapshot s,long hq,int index){return (s.access().root()||cell(s.actorMatrix(),hq,index+1))&&(!heritage(s)||bit(text(s.access().actor(),"emp_heritable_rights"),index));} + static Set distinct(List values,int max){if(values==null||values.size()>max||values.stream().anyMatch(Objects::isNull)||new HashSet<>(values).size()!=values.size())throw bad("Die Auswahl ist unvollständig oder enthält doppelte Einträge.");return new LinkedHashSet<>(values);} + Map saveRights(LegacyUser u,long employee,RightsSave request){ + u.require(3);if(request==null)throw bad("Rechteauswahl fehlt.");BusinessSupport.token(request.requestId()); + var rights=distinct(request.rights(),70);var inherit=distinct(request.heritable(),70);var branches=distinct(request.headquarters(),500);var cells=distinct(request.matrix(),35000); + support.transactional("employee","employeerights","genericdatacontainer"); + return db.employeeWriteTransaction(()->{ + var s=snapshot(u,employee);Object intent=List.of("EMPLOYEE_RIGHTS",employee,request);Long previous=support.receipt(u,request.requestId(),intent); + if(previous!=null)return fields("id",previous); + unchanged(s,request.revision());if(employee==u.employeeId()||s.protectedAccount())throw denied(); + Set valid=new LinkedHashSet<>();s.definitions().forEach(d->valid.add((int)id(d,"rgt_id"))); + if(!valid.containsAll(rights)||!valid.containsAll(inherit))throw bad("Unbekanntes Recht."); + var allowedBranches=new LinkedHashSet();s.access().branches().forEach(b->allowedBranches.add(id(b,"id"))); + if(!allowedBranches.containsAll(branches)||cells.stream().anyMatch(c->!allowedBranches.contains(c.headquarters())||!valid.contains(c.right())))throw denied(); + String before=text(s.person(),"emp_rights"),beforeHeritable=text(s.person(),"emp_heritable_rights"); + if(!before.matches("[01]{0,70}")||!beforeHeritable.matches("[01]{0,70}"))throw bad("Ungültige gespeicherte Rechtemaske."); + var next=new StringBuilder(before+"0".repeat(70-before.length()));var nextHeritable=new StringBuilder(beforeHeritable+"0".repeat(70-beforeHeritable.length())); + for(int i=0;i255)throw bad("Zu viele Niederlassungsfreigaben."); + for(long hq:allowedBranches)for(int key:valid){boolean wanted=cells.contains(new Cell(hq,key));boolean existed=cell(s.matrix(),hq,key);if(wanted==existed)continue;if(!canCell(s,hq,key-1))throw denied(); + if(wanted)db.update("INSERT INTO employeerights (emp_id,hq_id,rgt_id) VALUES (?,?,?)",employee,hq,key);else db.update("DELETE FROM employeerights WHERE emp_id=? AND hq_id=? AND rgt_id=?",employee,hq,key); + } + db.update("UPDATE employee SET emp_rights=?,emp_heritable_rights=?,emp_headquarters=? WHERE emp_id=?",next.toString(),nextHeritable.toString(),grantString,employee); + support.auditEmployee(u,employee,115,"EMPLOYEE="+employee+" | RIGHTS_BEFORE="+before+" | RIGHTS_AFTER="+next+" | HERITABLE_BEFORE="+beforeHeritable+" | HERITABLE_AFTER="+nextHeritable+" | HQ_BEFORE="+text(s.person(),"emp_headquarters")+" | HQ_AFTER="+grantString+" | MATRIX_BEFORE="+support.encode(s.matrix())+" | MATRIX_AFTER="+support.encode(matrix(employee)),request.requestId()); + support.receipt(u,request.requestId(),intent,employee);return fields("id",employee); + }); + } + Map saveProfile(LegacyUser u,long employee,ProfileSave r){return saveProfile(u,employee,r,false);} + Map saveProfile(LegacyUser u,long employee,ProfileSave r,boolean self){ + if(self){if(employee!=u.employeeId())throw denied();}else u.require(3);if(r==null)throw bad("Mitarbeiterdaten fehlen.");BusinessSupport.token(r.requestId()); + String name=required(r.name(),50,"Name"),first=required(r.firstName(),50,"Vorname"),account=required(r.account(),20,"Anmeldename"); + String email=optional(r.email(),250,"E-Mail"),phone=optional(r.phone(),20,"Telefon"),mobile=optional(r.mobile(),20,"Mobil"); + if(!email.isEmpty()&&!email.matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+"))throw bad("Bitte eine gültige E-Mail-Adresse eingeben."); + if(r.birthdate()!=null&&(r.birthdate().isBefore(LocalDate.of(1900,1,1))||r.birthdate().isAfter(LocalDate.now())))throw bad("Bitte einen gültigen Geburtstag eingeben."); + support.transactional("user","genericdatacontainer");return db.employeeWriteTransaction(()->{ + var s=self?selfSnapshot(u):snapshot(u,employee);Object intent=List.of("EMPLOYEE_PROFILE",employee,r);Long previous=support.receipt(u,r.requestId(),intent);if(previous!=null)return fields("id",previous); + unchanged(s,r.revision());if(s.protectedAccount()&&employee!=u.employeeId())throw denied(); + if(!db.queryForList("SELECT usr_id FROM user WHERE usr_account=? AND usr_id<>?",account,id(s.person(),"usr_id")).isEmpty())throw bad("Der Anmeldename wird bereits verwendet."); + db.update("UPDATE user SET usr_name=?,usr_firstname=?,usr_email=?,usr_phone=?,usr_phone2=?,usr_account=?,usr_birthdate=? WHERE usr_id=?",name,first,email,phone,mobile,account,r.birthdate(),id(s.person(),"usr_id")); + support.auditEmployee(u,employee,74,"EMPLOYEE="+employee+" | PROFILE_BEFORE="+support.encode(profile(s.person()))+" | PROFILE_AFTER="+support.encode(fields("name",name,"firstName",first,"email",email,"phone",phone,"mobile",mobile,"account",account,"birthdate",r.birthdate())),r.requestId()); + support.receipt(u,r.requestId(),intent,employee);return fields("id",employee); + }); + } + static String optional(String value,int max,String label){String s=value==null?"":value.strip();if(s.length()>max||s.chars().anyMatch(Character::isISOControl))throw bad(label+": ungültiger Wert.");return s;} + static AccessDeniedException denied(){return new AccessDeniedException("Diese Mitarbeiterdaten oder Rechte dürfen Sie nicht ändern.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeCallCenter.java b/backend/src/main/java/de/stadtbote/votian/EmployeeCallCenter.java new file mode 100644 index 0000000..62aa80e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeCallCenter.java @@ -0,0 +1,51 @@ +package de.stadtbote.votian; + +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; + +@RestController +class EmployeeCallCenter { + record Command(String requestId,String revision,boolean enabled) {} + record State(EmployeeAdministration.Access access,Map person,List> parameter,boolean protectedAccount) {} + final DatabaseService db;final BusinessSupport support; + EmployeeCallCenter(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/administration/employees/{employee}/call-center") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);} + @PutMapping("/api/administration/employees/{employee}/call-center") Object put(Authentication a,@PathVariable long employee,@RequestBody Command c){return save(ApiController.user(a),employee,c);} + State state(LegacyUser u,long employee){ + u.require(3);u.require(34); + if(employee<=0||u.headquartersId()!=u.originalHeadquartersId())throw EmployeeAdministration.denied(); + var access=new EmployeeAdministration(db,support).access(u); + if(!EmployeeAdministration.bit(text(access.actor(),"emp_rights"),34))throw EmployeeAdministration.denied(); + var person=one(db.queryForList(EmployeeAdministration.PERSON_SQL+" AND employee.emp_id=?",employee)); + if(access.branches().stream().noneMatch(b->id(b,"id")==id(person,"hq_id")))throw EmployeeAdministration.denied(); + if(db.queryForList("SELECT emp_id FROM employee WHERE usr_id=?",id(person,"usr_id")).size()!=1)throw bad("Das Mitarbeiterkonto ist nicht eindeutig zugeordnet."); + return new State(access,person,CallCenterAccess.rows(db,CallCenterAccess.EMPLOYEES),!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",employee).isEmpty()); + } + Map view(LegacyUser u,long employee){return db.readTransaction(()->{ + var s=state(u,employee);return fields("employee",EmployeeAdministration.profile(s.person()),"revision",revision(s),"enabled",CallCenterAccess.ids(CallCenterAccess.value(s.parameter())).contains(employee),"canSave",!s.protectedAccount(),"protectedAccount",s.protectedAccount()); + });} + Map save(LegacyUser u,long employee,Command c){ + u.require(3);u.require(34);if(c==null)throw bad("Die CC24-Auswahl fehlt.");BusinessSupport.token(c.requestId()); + support.transactional("parameter","genericdatacontainer"); + return db.callCenterWriteTransaction(()->{ + var s=state(u,employee);if(s.protectedAccount())throw EmployeeAdministration.denied(); + var intent=List.of("EMPLOYEE_CC24",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true); + if(!revision(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Der CC24-Zugang wurde zwischenzeitlich geändert. Bitte neu laden."); + String before=CallCenterAccess.value(s.parameter());boolean wasEnabled=CallCenterAccess.ids(before).contains(employee); + if(wasEnabled!=c.enabled()){ + // Preserve other employees (including other mandators), ordering, and row metadata. + String next=c.enabled()?before+"|"+employee:Arrays.stream(before.split("\\|",-1)).filter(v->!v.strip().equals(Long.toString(employee))).collect(java.util.stream.Collectors.joining("|")); + CallCenterAccess.ids(next); + if(s.parameter().isEmpty())db.insert("parameter",fields("emp_id",0,"hq_id",0,"md_id",0,"par_key",CallCenterAccess.EMPLOYEES,"par_value",next,"par_text","")); + else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",next,id(s.parameter().getFirst(),"par_id")); + } + support.auditEmployee(u,employee,115,"CC24_BEFORE="+wasEnabled+" | CC24_AFTER="+c.enabled(),c.requestId()); + support.receipt(u,c.requestId(),intent,employee);return fields("completed",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java b/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java new file mode 100644 index 0000000..6dfca98 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeCostCenterColumns.java @@ -0,0 +1,51 @@ +package de.stadtbote.votian; + +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; + +/** Personal columns consumed by PHP cs_csc_list.php and the native entry picker. */ +@RestController +class EmployeeCostCenterColumns { + static final String COLS="MASK_CSCSC_LIST_COLS", LENGTHS="MASK_CSCSC_LIST_LEN_COLS"; + static final List KEYS=List.of("cmp_comp","cmp_comp2","cs_eid","csc_name","cmp_match","cmp_authenticated_img","ad_street","cscad_hsno","ad_zipcode","ad_city","cscad_remark","cscad_person","cscad_phone","statistic","cs_report"); + static final List LABELS=List.of("Firma","Firma 2","ExtID","Kostenstelle","Freitext","Freigabe","Straße","Hausnr.","PLZ","Ort","Bemerkung","Person","Telefon","Statistik","Berichte"); + static final List LENGTH_DEFAULTS=List.of(30,15,11,20,30,5,20,5,5,20,20,20,15,0,0); + record Column(String key,int length){} + record Command(String requestId,String revision,List columns){} + record Layout(List columns,String warning){} + record State(EmployeeListPreferences.State access,List> rows){} + final DatabaseService db;final BusinessSupport support; + EmployeeCostCenterColumns(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/account/cost-center-columns") Object own(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId());} + @PutMapping("/api/account/cost-center-columns") Object ownSave(Authentication a,@RequestBody Command c){var u=ApiController.user(a);return save(u,u.employeeId(),c);} + @GetMapping("/api/administration/employees/{employee}/cost-center-columns") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);} + @PutMapping("/api/administration/employees/{employee}/cost-center-columns") Object put(Authentication a,@PathVariable long employee,@RequestBody Command c){return save(ApiController.user(a),employee,c);} + static List> rows(DatabaseService db,long employee,long hq,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 IN (?,?) ORDER BY emp_id,hq_id,par_key,par_id",employee,hq,home,md,COLS,LENGTHS);} + State state(LegacyUser u,long employee){var a=new EmployeeListPreferences(db,support).state(u,employee);return new State(a,rows(db,employee,u.headquartersId(),id(a.person(),"hq_id"),a.mandator()));} + 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[] 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;} + return new Layout(List.copyOf(columns),invalid?"Ungültige alte Spalten oder Längen wurden für die Anzeige ersetzt. Bitte die Auswahl vor dem Speichern prüfen.":""); + } + static Layout runtime(DatabaseService db,LegacyUser u){long md=mandator(db,u);var rows=rows(db,u.employeeId(),u.headquartersId(),u.originalHeadquartersId(),md);if(!writable(rows,md))throw bad("Die Kostenstellenspalten sind mehrfach vorhanden oder falsch zugeordnet. Bitte den Bestand zuerst klären.");return layout(rows,u.headquartersId());} + static String revisionOf(State s){return revision(List.of(s.access().person(),s.access().access().actor(),s.access().access().root(),s.access().mandator(),s.rows()));} + static List> definitions(){var result=new ArrayList>();for(int i=0;i view(LegacyUser u,long employee){return db.readTransaction(()->{var s=state(u,employee);var l=layout(s.rows(),u.headquartersId());boolean can=writable(s.rows(),s.access().mandator());return fields("employee",EmployeeAdministration.profile(s.access().person()),"headquartersId",u.headquartersId(),"columns",l.columns(),"definitions",definitions(),"revision",revisionOf(s),"canSave",u.headquartersId()==u.originalHeadquartersId()&&can,"warning",u.headquartersId()!=u.originalHeadquartersId()?"Persönliche Einstellungen können in der eigenen Niederlassung geändert werden.":can?l.warning():"Die Spaltenparameter sind mehrfach vorhanden oder falsch zugeordnet. Bitte den Bestand zuerst klären.");});} + static void validate(Command c){if(c==null||c.columns()==null)throw bad("Spalten fehlen.");BusinessSupport.token(c.requestId());if(c.columns().isEmpty()||c.columns().size()>21||c.columns().stream().anyMatch(x->x==null||x.key()==null||!KEYS.contains(x.key())||x.length()<0||x.length()>50))throw bad("Bitte eine bis 21 gültige Spalten mit Längen von 0 bis 50 auswählen.");} + 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())); + 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 new file mode 100644 index 0000000..0e023d3 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeHistory.java @@ -0,0 +1,142 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.dao.DataAccessException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.time.*; +import java.util.*; +import java.util.regex.Pattern; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Read-only employee history. Never return raw PHP descriptions: they can contain password hashes. */ +@RestController +class EmployeeHistory { + final DatabaseService db;final BusinessSupport support;final EmployeeAdministration employees; + static final List TABLES=List.of("log","loghistory","loghistory2","loghistory3"); + static final Map OPERATIONS=Map.of(73,"Mitarbeiter angelegt",74,"Mitarbeiter geändert",75,"Mitarbeiter gelöscht",76,"Passwort geändert",115,"Rechte geändert",116,"Niederlassungsfreigaben geändert",184,"Zwei-Faktor-Anmeldung zurückgesetzt",203,"Listenparameter geändert"); + EmployeeHistory(DatabaseService db,BusinessSupport support,EmployeeAdministration employees){this.db=db;this.support=support;this.employees=employees;} + @GetMapping("/api/administration/employees/{employee}/history") Object get(Authentication a,@PathVariable long employee,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to,@RequestParam(defaultValue="log") String source){return history(ApiController.user(a),employee,from,to,source);} + static boolean allowed(EmployeeAdministration.Access a,boolean protectedAccount){return a.root()||EmployeeAdministration.bit(text(a.actor(),"emp_rights"),32)&&!protectedAccount;} + Map history(LegacyUser u,long employee,LocalDate from,LocalDate to,String source){ + LocalDate start=from==null?LocalDate.now():from,end=to==null?start:to; + if(start.isAfter(end)||start.getYear()<1900||end.getYear()>9998)throw bad("Bitte einen gültigen Zeitraum ab 1900 wählen."); + if(!TABLES.contains(source))throw bad("Unbekannter Historienbestand."); + return db.readTransaction(()->{ + var s=employees.snapshot(u,employee);if(!allowed(s.access(),s.protectedAccount()))throw EmployeeAdministration.denied(); + try{ + var available=db.queryForList("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN ('log','loghistory','loghistory2','loghistory3')",support.historySchema.replace("`","")).stream().map(r->text(r,"name")).filter(TABLES::contains).sorted(Comparator.comparingInt(TABLES::indexOf)).toList(); + if(!available.contains(source))throw bad("Dieser Historienbestand ist nicht verfügbar."); + String select="SELECT l.log_id,l.logo_id,l.log_createtime,l.usr_id,l.logo_description,o.logo_op_shortname,u.usr_name,u.usr_firstname,u.usr_phone FROM "+support.historySchema+".`"+source+"` l JOIN "+support.historySchema+".logoperation o ON o.logo_id=l.logo_id LEFT JOIN user u ON u.usr_id=l.usr_id WHERE (l.emp_id=? OR (l.emp_id=0 AND l.logo_id IN (73,74,75,76,115,116,184) AND l.logo_description LIKE ?)) AND (o.logo_op_category='emp' OR l.logo_id IN (184,203)) AND l.log_createtime>=? AND l.log_createtime>();for(var r:raw)items.add(item(r,source,false,definitions,branches)); + // Read queued events too, without publishing or touching the MyISAM history from a GET. + if(source.equals("log")){ + var queued=db.queryForList("SELECT g.gdc_id,g.gdc_content,u.usr_name,u.usr_firstname,u.usr_phone FROM genericdatacontainer g LEFT JOIN user u ON u.usr_id=JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.user')) WHERE g.gdc_obj_type='ngaudit' AND g.gdc_context='PENDING' AND (JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.employee'))=? OR JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.description')) LIKE ?) AND JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.at'))>=? AND JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.at')) published=new HashSet<>();raw.forEach(r->published.add(text(r,"logo_description"))); + for(var q:queued){BusinessSupport.Audit a;try{a=support.json.readValue(text(q,"gdc_content"),BusinessSupport.Audit.class);}catch(Exception ex){continue;} + if(BusinessSupport.employeeId(a)!=employee||!OPERATIONS.containsKey(a.operation())||published.contains(a.description()))continue; + var r=new LinkedHashMap<>(q);r.putAll(fields("log_id",id(q,"gdc_id"),"logo_id",a.operation(),"log_createtime",a.at(),"usr_id",a.user(),"logo_description",a.description()));items.add(item(r,"pending",true,definitions,branches)); + } + } + items.sort(Comparator.,String>comparing(r->text(r,"at")).reversed().thenComparing(Comparator.comparingLong((Map r)->id(r,"id")).reversed())); + return fields("employee",EmployeeAdministration.profile(s.person()),"from",start,"to",end,"source",source,"sources",available,"items",items.stream().limit(2500).toList(),"truncated",items.size()>2500); + }catch(DataAccessException ex){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Die Mitarbeiterhistorie ist derzeit nicht erreichbar. Bitte erneut versuchen.");} + }); + } + Map item(Map r,String source,boolean pending,List> rights,List> branches){ + int operation=(int)id(r,"logo_id");String name=text(r,"logo_op_shortname");if(name.isBlank())name=OPERATIONS.getOrDefault(operation,"Vorgang "+operation); + String actor=join(text(r,"usr_firstname"),text(r,"usr_name"));if(actor.isBlank())actor="Benutzer "+id(r,"usr_id"); + return fields("id",id(r,"log_id"),"key",source+":"+id(r,"log_id"),"at",text(r,"log_createtime").replace(' ','T'),"action",name,"actor",actor,"phone",text(r,"usr_phone"),"detail",details(text(r,"logo_description"),rights,branches),"pending",pending); + } + String details(String raw,List> rights,List> branches){ + String value=raw==null?"":raw.replace("","").replace("","");var result=new ArrayList();var values=new LinkedHashMap(); + for(String part:value.split("\\|")){int n=part.indexOf('=');if(n>0)values.put(part.substring(0,n).strip(),part.substring(n+1).strip());} + for(String key:List.of("STATUS_NEW","STATUS_MODIFIED","STATUS_DELETED","USER_INFO"))if(values.containsKey(key)){ + String[] parts=values.get(key).split(";",-1);int[] indexes=key.equals("STATUS_NEW")?new int[]{1,2,3,4,6}:new int[]{0,1,2}; + var safe=new ArrayList();for(int i:indexes)if(i();for(String hq:values.get(key).split(","))if(hq.strip().matches("[0-9]+"))names.add(branches.stream().filter(b->Long.toString(id(b,"hq_id")).equals(hq.strip())).map(b->text(b,"hq_mnemonic")).findFirst().orElse("Niederlassung "+hq.strip())); + result.add("Niederlassungen ("+(key.endsWith("OLD")||key.endsWith("BEFORE")?"vorher":"nachher")+"): "+(names.isEmpty()?"keine ausdrückliche Freigabe":String.join(", ",names))); + } + for(String key:List.of("CC24_BEFORE","CC24_AFTER"))if(Set.of("true","false").contains(values.getOrDefault(key,""))) + result.add("CC24-Zugang ("+(key.endsWith("BEFORE")?"vorher":"nachher")+"): "+(values.get(key).equals("true")?"Ja":"Nein")); + for(String key:List.of("STOCK_RIGHTS_BEFORE","STOCK_RIGHTS_AFTER")){ + 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));}} + 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.");} + } + for(String key:List.of("LIST_PREFERENCES_BEFORE","LIST_PREFERENCES_AFTER")){ + 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);var labels=Map.of("pageSize","Einträge","vehicleMax","Fahrzeuggrenze","copyNumber","Nummer übernehmen","keepRefresh","Aktualisierung beibehalten","courierNotes","Transporteurbemerkungen","gray","Graue Darstellung","copyAccounting","Kopie für Buchhaltung","laterAccounting","Nacherfassung vorbelegen"); + for(String field:List.of("pageSize","vehicleMax","copyNumber","keepRefresh","courierNotes","gray","copyAccounting","laterAccounting"))if(node.hasNonNull(field)&&(node.get(field).isBoolean()||node.get(field).isIntegralNumber()))result.add(labels.get(field)+(key.endsWith("BEFORE")?" (vorher)":" (nachher)")+": "+node.get(field).asText()); + }catch(Exception ignored){result.add("Listenparameter geändert.");} + } + for(String key:List.of("PARALLEL_LISTS_BEFORE","PARALLEL_LISTS_AFTER")){ + 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("count").isIntegralNumber())result.add("Listenanzahl"+suffix+": "+node.get("count").asInt()); + var statuses=node.path("statuses");if(statuses.isArray()&&statuses.size()==4){var names=Map.of(0,"Offen",1,"Angenommen",2,"Erledigt",8,"Autoranking",9,"Zuzuweisen",10,"Alle");for(int i=0;i<4;i++)if(statuses.get(i).isIntegralNumber()&&names.containsKey(statuses.get(i).asInt()))result.add("Liste "+(i+1)+suffix+": "+names.get(statuses.get(i).asInt()));} + }catch(Exception ignored){result.add("Mehrfachlisten geändert.");} + } + for(String key:List.of("MASTER_COLUMNS_BEFORE","MASTER_COLUMNS_AFTER")){ + String kindName=values.get("MASTER_COLUMNS_KIND");if(!Set.of("customers","couriers").contains(Objects.toString(kindName,"")))continue;var kind=MasterListColumns.Kind.valueOf(kindName); + 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);var labels=new ArrayList();if(node.isArray()&&node.size()<=kind.keys.size())for(var n:node)if(n.isTextual()&&kind.keys.contains(n.asText()))labels.add(kind.label(n.asText()));result.add((kind==MasterListColumns.Kind.customers?"Kundenspalten":"Transporteurspalten")+(key.endsWith("BEFORE")?" (vorher): ":" (nachher): ")+String.join(", ",labels));}catch(Exception ignored){result.add("Listenspalten geändert.");} + } + for(String key:List.of("ARTICLE_COLUMNS_BEFORE","ARTICLE_COLUMNS_AFTER")){ + 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);var labels=new ArrayList();if(node.isArray()&&node.size()<=ArticleColumns.KEYS.size())for(var n:node)if(n.isTextual()&&ArticleColumns.KEYS.contains(n.asText()))labels.add(ArticleColumns.LABELS.get(ArticleColumns.KEYS.indexOf(n.asText())));result.add("Artikellistenspalten"+(key.endsWith("BEFORE")?" (vorher): ":" (nachher): ")+String.join(", ",labels));}catch(Exception ignored){result.add("Artikellistenspalten geändert.");} + } + for(String key:List.of("ORDER_COLUMNS_BEFORE","ORDER_COLUMNS_AFTER")){ + 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);var labels=new ArrayList();if(node.isArray()&&node.size()<=OrderListColumns.KEYS.size())for(var n:node)if(n.isTextual()&&OrderListColumns.KEYS.contains(n.asText()))labels.add(OrderListColumns.label(n.asText()));result.add("Auftragslistenspalten"+(key.endsWith("BEFORE")?" (vorher): ":" (nachher): ")+String.join(", ",labels));}catch(Exception ignored){result.add("Auftragslistenspalten geändert.");} + } + for(String key:List.of("COST_CENTER_COLUMNS_BEFORE","COST_CENTER_COLUMNS_AFTER")){ + 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);var columns=new ArrayList(); + if(node.isArray()&&node.size()<=21)for(var col:node){String field=col.path("key").asText();int index=EmployeeCostCenterColumns.KEYS.indexOf(field);if(index>=0&&col.path("length").isIntegralNumber()&&col.path("length").asInt()>=0&&col.path("length").asInt()<=50)columns.add(EmployeeCostCenterColumns.LABELS.get(index)+" ("+col.path("length").asInt()+")");} + result.add("Kostenstellenspalten"+(key.endsWith("BEFORE")?" (vorher): ":" (nachher): ")+String.join(", ",columns)); + }catch(Exception ignored){result.add("Kostenstellenspalten geändert.");} + } + if(value.contains("PASSWORD_CHANGED")||values.containsKey("PASSWORD_OLD")||values.containsKey("PASSWORD_NEW"))result.add("Passwort geändert. Geheimnisse werden nicht angezeigt."); + if(value.contains("TWO_FACTOR_ACTIVATED"))result.add("Zwei-Faktor-Anmeldung aktiviert.");if(value.contains("TWO_FACTOR_RESET"))result.add("Zwei-Faktor-Anmeldung zurückgesetzt."); + if(values.containsKey("DELETED_RIGHTS")){ + result.add("Bisherige Niederlassungsrechte entfernt: "+(values.get("DELETED_RIGHTS").equals("YES")?"ja":"nein")); + for(String part:value.split("\\|"))if(part.strip().matches("[1-9][0-9]*_[1-9][0-9]*")){String[] ids=part.strip().split("_");String branch=branches.stream().filter(b->Long.toString(id(b,"hq_id")).equals(ids[0])).map(b->text(b,"hq_mnemonic")).findFirst().orElse(ids[0]);String right=rights.stream().filter(r->Long.toString(id(r,"rgt_id")).equals(ids[1])).map(r->text(r,"rgt_name")).findFirst().orElse("Recht "+ids[1]);result.add(right+" in "+branch+": vergeben");} + } + if(values.containsKey("MATRIX_BEFORE")||values.containsKey("MATRIX_AFTER"))matrix(result,value,rights,branches); + return result.isEmpty()?"Für diesen Vorgang sind keine darstellbaren Detailangaben hinterlegt.":String.join("\n",result); + } + static void rights(List result,String before,String after,String label,List> definitions){ + if(!before.matches("[01]{0,70}")||!after.matches("[01]{1,70}"))return; + for(int i=0;iid(r,"rgt_id")==id).map(r->text(r,"rgt_name")).findFirst().orElse("Recht "+id);result.add(label+" "+name+": "+(EmployeeAdministration.bit(after,i)?"vergeben":"entzogen")); + } + } + void matrix(List result,String raw,List> rights,List> branches){ + try{var before=matrixValue(raw,"MATRIX_BEFORE");var after=matrixValue(raw,"MATRIX_AFTER");var all=new TreeSet(before);all.addAll(after);for(String cell:all)if(before.contains(cell)!=after.contains(cell)){ + String[] ids=cell.split(":");String branch=branches.stream().filter(b->Long.toString(id(b,"hq_id")).equals(ids[0])).map(b->text(b,"hq_mnemonic")).findFirst().orElse(ids[0]);String right=rights.stream().filter(r->Long.toString(id(r,"rgt_id")).equals(ids[1])).map(r->text(r,"rgt_name")).findFirst().orElse("Recht "+ids[1]);result.add(right+" in "+branch+": "+(after.contains(cell)?"vergeben":"entzogen")); + }}catch(Exception ignored){result.add("Niederlassungsrechte geändert.");} + } + Set matrixValue(String raw,String key)throws Exception{var m=Pattern.compile("(?:^| \\| )"+key+"=").matcher(raw);if(!m.find())throw new IllegalArgumentException();try(var p=support.json.createParser(raw.substring(m.end()))){var out=new TreeSet();com.fasterxml.jackson.databind.JsonNode nodes=support.json.readTree(p);if(!nodes.isArray())throw new IllegalArgumentException();for(var n:nodes)out.add(n.path("hq_id").asLong()+":"+n.path("rgt_id").asLong());return out;}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeListPreferences.java b/backend/src/main/java/de/stadtbote/votian/EmployeeListPreferences.java new file mode 100644 index 0000000..ee9e8f2 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeListPreferences.java @@ -0,0 +1,79 @@ +package de.stadtbote.votian; + +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; + +/** Personal PHP job-list parameters and their native runtime interpretation. */ +@RestController +class EmployeeListPreferences { + static final List KEYS=List.of("MASK_JOBLIST_BROWSE_MAX","MASK_JOBLIST_SHOW_UPTO_VHT","MASK_JOBLIST_MODE_JOB_NUM","MASK_JOBLIST_MODE_REFRESH","MASK_JOBLIST_GET_COURIER_DATA","MASK_CONTENT_BGCOL","MODE_COPY_JOB","MODE_LATER_JOB"); + record Values(int pageSize,int vehicleMax,boolean copyNumber,boolean keepRefresh,boolean courierNotes,boolean gray,boolean copyAccounting,boolean laterAccounting){ + Values(int size,int vehicle,boolean copy,boolean refresh,boolean notes,boolean gray){this(size,vehicle,copy,refresh,notes,gray,false,false);} + } + record Command(String requestId,String revision,Values values){} + record State(EmployeeAdministration.Access access,Map person,long mandator,List> rows,List> vehicles){} + final DatabaseService db;final BusinessSupport support; + EmployeeListPreferences(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/account/list-preferences") Object own(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId());} + @PutMapping("/api/account/list-preferences") Object ownSave(Authentication a,@RequestBody Command c){var u=ApiController.user(a);return save(u,u.employeeId(),c);} + @GetMapping("/api/administration/employees/{employee}/list-preferences") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);} + @PutMapping("/api/administration/employees/{employee}/list-preferences") Object put(Authentication a,@PathVariable long employee,@RequestBody Command c){return save(ApiController.user(a),employee,c);} + static List> rows(DatabaseService db,long employee,long headquarters,long original){ + 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 (?,?) AND par_key IN ("+String.join(",",Collections.nCopies(KEYS.size(),"?"))+") ORDER BY hq_id,par_key,par_id",args(employee,headquarters,original)); + } + static Object[] args(long emp,long hq,long original){var args=new ArrayList(List.of(emp,hq,original));args.addAll(KEYS);return args.toArray();} + static boolean unique(List> rows){return rows.stream().map(r->id(r,"hq_id")+":"+text(r,"par_key")).distinct().count()==rows.size();} + static String value(List> rows,String key,long hq){var matching=rows.stream().filter(r->text(r,"par_key").equals(key)).toList();return matching.stream().filter(r->id(r,"hq_id")==hq&&!text(r,"par_value").isEmpty()).findFirst().or(()->matching.stream().filter(r->id(r,"hq_id")!=hq).findFirst()).map(r->text(r,"par_value")).orElse("");} + static Values values(List> rows,long hq){ + String size=value(rows,KEYS.get(0),hq),vehicle=value(rows,KEYS.get(1),hq); + int limit=size.matches("[0-9]{1,4}")?Integer.parseInt(size):100;if(limit<100||limit>1000)limit=100; + int max=vehicle.matches("[0-9]{1,6}")?Integer.parseInt(vehicle):0; + return new Values(limit,max,value(rows,KEYS.get(2),hq).equals("1"),value(rows,KEYS.get(3),hq).equals("1"),value(rows,KEYS.get(4),hq).equals("1"),value(rows,KEYS.get(5),hq).equals("1"),value(rows,KEYS.get(6),hq).equals("1"),value(rows,KEYS.get(7),hq).equals("1")); + } + static Values runtime(DatabaseService db,LegacyUser u){var rows=rows(db,u.employeeId(),u.headquartersId(),u.originalHeadquartersId());if(!unique(rows))throw bad("Die persönlichen Listenparameter sind mehrfach vorhanden. Bitte den Bestand zuerst klären.");return values(rows,u.headquartersId());} + State state(LegacyUser u,long employee){ + if(employee<=0)throw EmployeeAdministration.denied(); + if(u.headquartersId()!=u.originalHeadquartersId()){ + if(employee!=u.employeeId())throw EmployeeAdministration.denied(); + new WorkspaceHeadquarters(db).select(u,u.headquartersId()); + } + var a=new EmployeeAdministration(db,support).access(u,employee==u.employeeId()); + var person=employee==u.employeeId()?a.actor():one(db.queryForList(EmployeeAdministration.PERSON_SQL+" AND employee.emp_id=?",employee)); + if(a.branches().stream().noneMatch(b->id(b,"id")==id(person,"hq_id")))throw EmployeeAdministration.denied(); + if(employee!=u.employeeId()&&!a.root()&&(!EmployeeAdministration.bit(text(a.actor(),"emp_rights"),50)||!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",employee).isEmpty()))throw EmployeeAdministration.denied(); + long md=mandator(db,u);var rows=rows(db,employee,u.headquartersId(),id(person,"hq_id")); + var vehicles=db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' AND mt_sort>0 ORDER BY mt_sort"); + if(vehicles.stream().map(r->id(r,"id")).distinct().count()!=vehicles.size())throw bad("Die Fahrzeugtypen sind nicht eindeutig."); + return new State(a,person,md,rows,vehicles); + } + static String revisionOf(State s){return revision(List.of(s.person(),s.access().actor(),s.access().root(),s.mandator(),s.rows(),s.vehicles()));} + static boolean writable(State s){return unique(s.rows())&&s.rows().stream().allMatch(r->id(r,"md_id")==0||id(r,"md_id")==s.mandator());} + Map view(LegacyUser u,long employee){return db.readTransaction(()->{ + var s=state(u,employee);var v=values(s.rows(),u.headquartersId());var warnings=new ArrayList();if(u.headquartersId()!=u.originalHeadquartersId())warnings.add("Persönliche Einstellungen können in der eigenen Niederlassung geändert werden."); + if(!writable(s))warnings.add("Die persönlichen Listenparameter sind mehrfach vorhanden oder falsch zugeordnet. Bitte den Bestand zuerst klären."); + if(v.vehicleMax()>0&&s.vehicles().stream().noneMatch(r->id(r,"id")==v.vehicleMax()))warnings.add("Die gespeicherte Fahrzeuggrenze ist nicht mehr im Katalog. Sie bleibt beim Lesen wirksam; bitte eine aktuelle Grenze auswählen."); + for(String key:KEYS){String raw=value(s.rows(),key,u.headquartersId());if(!raw.isEmpty()&&(key.equals(KEYS.get(0))?!raw.matches("[1-9]00|1000"):key.equals(KEYS.get(1))?!raw.matches("[0-9]{1,6}"):!Set.of("0","1").contains(raw)))warnings.add("Ungültiger Altwert bei "+key+"; bitte die angezeigte Vorgabe prüfen und speichern.");} + return fields("employee",EmployeeAdministration.profile(s.person()),"headquartersId",u.headquartersId(),"values",v,"initialDates",OrderListDateDefaults.read(db,u),"vehicles",s.vehicles(),"revision",revisionOf(s),"canSave",u.headquartersId()==u.originalHeadquartersId()&&writable(s),"warning",String.join(" ",warnings)); + });} + static void validate(Command c){if(c==null||c.values()==null)throw bad("Listenparameter fehlen.");BusinessSupport.token(c.requestId());var v=c.values();if(v.pageSize()<100||v.pageSize()>1000||v.pageSize()%100!=0||v.vehicleMax()<0)throw bad("Ungültige Listenlänge oder Fahrzeuggrenze.");} + 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_LIST_PREFERENCES",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true); + if(!writable(s))throw bad("Die Listenparameter sind nicht eindeutig zugeordnet."); + if(!revisionOf(s).equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Listenparameter wurden zwischenzeitlich geändert. Bitte neu laden."); + var v=c.values();if(v.vehicleMax()>0&&s.vehicles().stream().noneMatch(r->id(r,"id")==v.vehicleMax()))throw bad("Der Fahrzeugtyp ist nicht mehr verfügbar."); + var saved=List.of(Integer.toString(v.pageSize()),Integer.toString(v.vehicleMax()),v.copyNumber()?"1":"0",v.keepRefresh()?"1":"0",v.courierNotes()?"1":"0",v.gray()?"1":"0",v.copyAccounting()?"1":"0",v.laterAccounting()?"1":"0"); + for(int i=0;iid(r,"hq_id")==u.headquartersId()&&text(r,"par_key").equals(key)).findFirst(); + if(current.isPresent())db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(current.get(),"par_id")); + else db.insert("parameter",fields("par_key",key,"par_value",value,"par_text","","emp_id",employee,"hq_id",u.headquartersId(),"md_id",s.mandator())); + } + support.auditEmployee(u,employee,203,"LIST_PREFERENCES_BEFORE="+support.encode(values(s.rows(),u.headquartersId()))+" | LIST_PREFERENCES_AFTER="+support.encode(v),c.requestId());support.receipt(u,c.requestId(),intent,employee);return fields("completed",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java b/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java new file mode 100644 index 0000000..86d637b --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeParallelLists.java @@ -0,0 +1,62 @@ +package de.stadtbote.votian; + +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; + +/** PHP's independent list frames and their personal initial status selections. */ +@RestController +class EmployeeParallelLists { + static final String COUNT="MASK_MULTI_JOBLIST", STATUSES="MASK_JOBLIST_DEFAULTLIST", MAX="MASK_MULTI_JOBLIST_MAX"; + static final Set ALLOWED=Set.of(0,1,2,8,9,10); + static final List DEFAULTS=List.of(8,9,0,1); + record Values(int count,List statuses){} + record Command(String requestId,String revision,Values values){} + record State(EmployeeListPreferences.State access,List> rows){} + final DatabaseService db;final BusinessSupport support; + EmployeeParallelLists(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/account/parallel-lists") Object own(Authentication a){var u=ApiController.user(a);return view(u,u.employeeId());} + @PutMapping("/api/account/parallel-lists") Object ownSave(Authentication a,@RequestBody Command c){var u=ApiController.user(a);return save(u,u.employeeId(),c);} + @GetMapping("/api/administration/employees/{employee}/parallel-lists") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);} + @PutMapping("/api/administration/employees/{employee}/parallel-lists") Object put(Authentication a,@PathVariable long employee,@RequestBody Command c){return save(ApiController.user(a),employee,c);} + State state(LegacyUser u,long employee){ + var access=new EmployeeListPreferences(db,support).state(u,employee); + var rows=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 IN (0,?) AND md_id IN (0,?))) AND par_key IN (?,?,?) ORDER BY emp_id,hq_id,par_key,par_id",employee,u.headquartersId(),id(access.person(),"hq_id"),u.headquartersId(),access.mandator(),COUNT,STATUSES,MAX); + return new State(access,rows); + } + static boolean writable(State s){return s.rows().stream().map(r->id(r,"emp_id")+":"+id(r,"hq_id")+":"+text(r,"par_key")).distinct().count()==s.rows().size()&&s.rows().stream().allMatch(r->id(r,"md_id")==0||id(r,"md_id")==s.access().mandator());} + static String personal(State s,String key,long hq){return EmployeeListPreferences.value(s.rows().stream().filter(r->id(r,"emp_id")>0).toList(),key,hq);} + static String global(State s,String key,long hq){return EmployeeListPreferences.value(s.rows().stream().filter(r->id(r,"emp_id")==0).toList(),key,hq);} + static int max(State s,long hq){String raw=global(s,MAX,hq);return raw.matches("[1-4]")?Integer.parseInt(raw):4;} + static Values values(State s,long hq){ + String count=personal(s,COUNT,hq);if(count.isBlank())count=global(s,COUNT,hq);int n=count.matches("[1-4]")?Integer.parseInt(count):1; + String statuses=personal(s,STATUSES,hq);if(statuses.isBlank())statuses=global(s,STATUSES,hq); + String[] parts=statuses.split(",",-1);var selected=new ArrayList(); + for(int i=0;i<4;i++){String part=i view(LegacyUser u,long employee){return db.readTransaction(()->{ + var s=state(u,employee);var warnings=new ArrayList();if(u.headquartersId()!=u.originalHeadquartersId())warnings.add("Persönliche Einstellungen können in der eigenen Niederlassung geändert werden.");if(!writable(s))warnings.add("Die Listenvorgaben sind mehrfach vorhanden oder falsch zugeordnet. Bitte den Bestand zuerst klären."); + String count=personal(s,COUNT,u.headquartersId()),statuses=personal(s,STATUSES,u.headquartersId()); + if(!count.isBlank()&&!count.matches("[1-4]")||!statuses.isBlank()&&!statuses.matches("(?:0|1|2|8|9|10),(?:0|1|2|8|9|10),(?:0|1|2|8|9|10),(?:0|1|2|8|9|10)"))warnings.add("Ungültige alte Listenvorgaben: Bitte die angezeigten Ersatzwerte prüfen und speichern."); + return fields("employee",EmployeeAdministration.profile(s.access().person()),"headquartersId",u.headquartersId(),"values",values(s,u.headquartersId()),"max",max(s,u.headquartersId()),"revision",revisionOf(s),"canSave",u.headquartersId()==u.originalHeadquartersId()&&writable(s),"warning",String.join(" ",warnings)); + });} + static void validate(Command c){if(c==null||c.values()==null)throw bad("Listenvorgaben fehlen.");BusinessSupport.token(c.requestId());var v=c.values();if(v.count()<1||v.count()>4||v.statuses()==null||v.statuses().size()!=4||v.statuses().stream().anyMatch(x->x==null||!ALLOWED.contains(x)))throw bad("Bitte eine bis vier Listen und vier gültige Startstatus auswählen.");} + 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_PARALLEL_LISTS",employee,c);if(support.receipt(u,c.requestId(),intent)!=null)return fields("completed",true); + 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())); + 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 new file mode 100644 index 0000000..6f75a8a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EmployeeStockRights.java @@ -0,0 +1,84 @@ +package de.stadtbote.votian; + +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; +import static de.stadtbote.votian.StockAccess.*; + +@RestController +class EmployeeStockRights { + record Grant(long root,boolean enabled,List subtrees) {} + record Command(String requestId,String revision,List grants,boolean readOnly,boolean articles) {} + record State(EmployeeAdministration.Access access,Map person,long mandator,List> actorRows,List> targetRows,List> stocks,boolean protectedAccount,long targetHeadquarters) {} + final DatabaseService db;final BusinessSupport support; + EmployeeStockRights(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/administration/employees/{employee}/stock-rights") Object get(Authentication a,@PathVariable long employee){return view(ApiController.user(a),employee);} + @PutMapping("/api/administration/employees/{employee}/stock-rights") Object put(Authentication a,@PathVariable long employee,@RequestBody Command c){return save(ApiController.user(a),employee,c);} + String personSql(){return EmployeeAdministration.PERSON_SQL;} + long configurationHeadquarters(LegacyUser u,Map person){return u.headquartersId();} + State state(LegacyUser u,long employee){ + if(employee<=0||u.headquartersId()!=u.originalHeadquartersId())throw EmployeeAdministration.denied(); + var access=new EmployeeAdministration(db,support).access(u);var person=one(db.queryForList(personSql()+" AND employee.emp_id=?",employee)); + if(access.branches().stream().noneMatch(b->id(b,"id")==id(person,"hq_id")))throw EmployeeAdministration.denied(); + long md=mandator(db,u); + // Warehouses are shared within the mandator, independently of the employee-list HQ filter. + var stocks=db.queryForList("SELECT stock.stk_id,stock.stk_pre_id,stock.stk_name,stock.stk_path,stock.hq_id FROM stock WHERE stock.hq_id IN (SELECT hq_id FROM mandatorheadquarters WHERE md_id=?) ORDER BY stock.stk_name,stock.stk_id",md); + if(stocks.size()>50000||stocks.stream().map(r->id(r,"stk_id")).distinct().count()!=stocks.size())throw bad("Die Lagerhierarchie ist nicht eindeutig oder zu groß."); + long targetHeadquarters=configurationHeadquarters(u,person);var a=rows(db::queryForList,u.employeeId(),u.headquartersId(),u.originalHeadquartersId());var t=rows(db::queryForList,employee,targetHeadquarters,id(person,"hq_id")); + for(var rs:List.of(a,t))if(rs.stream().anyMatch(r->id(r,"md_id")!=0&&id(r,"md_id")!=md))throw bad("Die Lagerparameter gehören zu einem anderen Mandanten."); + settings(a,u.headquartersId());settings(t,targetHeadquarters); + return new State(access,person,md,a,t,stocks,!db.queryForList("SELECT hq_id FROM headquarters WHERE hq_admin=?",employee).isEmpty(),targetHeadquarters); + } + static String revisionOf(State s){return revision(List.of(s.access(),s.person(),s.mandator(),s.actorRows(),s.targetRows(),s.stocks(),s.protectedAccount(),s.targetHeadquarters()));} + static boolean editable(LegacyUser u,State s){return u.employeeId()!=id(s.person(),"emp_id")&&!s.protectedAccount();} + static Map> index(State s){var map=new LinkedHashMap>();s.stocks().forEach(r->map.put(id(r,"stk_id"),r));return map;} + static List chain(Map> map,long id){var result=new ArrayList();while(id!=0){if(result.contains(id)||result.size()>=50||!map.containsKey(id))throw bad("Die Lagerhierarchie ist unvollständig oder enthält einen Kreis.");result.add(id);id=id(map.get(id),"stk_pre_id");}return result;} + static List roots(State s){return s.stocks().stream().filter(r->id(r,"stk_pre_id")==0&&text(r,"stk_path").isEmpty()).map(r->id(r,"stk_id")).toList();} + static List selection(Settings v,long root){var set=v.subtrees().get(root);return set==null||set.contains(root)?List.of():set.stream().sorted().toList();} + static boolean covers(State s,Settings actor,long root,long node){return s.access().root()||actor.roots().contains(root)&&(selection(actor,root).isEmpty()||chain(index(s),node).stream().anyMatch(selection(actor,root)::contains));} + static boolean canRoot(State s,Settings a,long root){return s.access().root()||a.roots().contains(root);} + static boolean canSub(State s,Settings a,Settings t,long root){return canRoot(s,a,root)&&(!t.roots().contains(root)||(selection(t,root).isEmpty()?covers(s,a,root,root):selection(t,root).stream().allMatch(n->covers(s,a,root,n))));} + static boolean flags(State s,Settings a){return s.access().root()||EmployeeAdministration.bit(text(s.access().actor(),"emp_rights"),14)&&!a.readOnly()&&!a.locked();} + Map view(LegacyUser u,long employee){return db.readTransaction(()->{ + var s=state(u,employee);var a=settings(s.actorRows(),u.headquartersId());var t=settings(s.targetRows(),s.targetHeadquarters());boolean edit=editable(u,s);var map=index(s);var grants=new ArrayList>(); + for(long root:roots(s)){ + boolean allowed=canRoot(s,a,root),sub=canSub(s,a,t,root);var options=new ArrayList>(); + if(allowed)for(var row:s.stocks()){var path=chain(map,id(row,"stk_id"));if(path.getLast()!=root||!covers(s,a,root,id(row,"stk_id")))continue;var names=new ArrayList();for(long node:path.reversed())names.add(text(map.get(node),"stk_name"));options.add(fields("id",id(row,"stk_id"),"label",String.join(" / ",names)));} + var selected=selection(t,root);var labels=selected.stream().map(n->fields("id",n,"label",map.containsKey(n)?text(map.get(n),"stk_name")+" (#"+n+")":"Unbekanntes Lager #"+n)).toList(); + grants.add(fields("root",root,"name",text(map.get(root),"stk_name"),"headquartersId",id(map.get(root),"hq_id"),"enabled",t.roots().contains(root),"subtrees",selected,"selectedLabels",labels,"canAccess",edit&&allowed,"canSubtrees",edit&&sub,"canUnrestrict",edit&&sub&&covers(s,a,root,root),"options",options)); + } + return fields("employee",EmployeeAdministration.profile(s.person()),"headquartersId",s.targetHeadquarters(),"revision",revisionOf(s),"grants",grants,"readOnly",t.readOnly(),"articles",t.articles(),"canSave",edit,"canReadOnly",edit&&flags(s,a),"canArticles",edit&&(s.access().root()||EmployeeAdministration.bit(text(s.access().actor(),"emp_rights"),14)),"warning",!edit?"Eigene Lagerrechte und Administratorkonten sind geschützt.":"Nicht änderbare Lagerfreigaben bleiben erhalten."); + });} + Map save(LegacyUser u,long employee,Command c){ + u.require(3);if(c==null)throw bad("Lagerrechte fehlen.");BusinessSupport.token(c.requestId());EmployeeAdministration.distinct(c.grants(),5000); + support.transactional("parameter","genericdatacontainer");return db.employeeStockRightsWriteTransaction(()->{ + 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."); + 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(); + if(changed||enabledChanged&&g.enabled()){ + if(selected.isEmpty()&&!covers(s,a,g.root(),g.root()))throw bad("Ein uneingeschränktes Hauptlager überschreitet Ihre Unterlagerfreigabe."); + for(long node:selected)if(node==g.root()||chain(map,node).getLast()!=g.root()||!covers(s,a,g.root(),node))throw EmployeeAdministration.denied(); + if(selected.stream().anyMatch(n->chain(map,n).stream().skip(1).anyMatch(selected::contains)))throw bad("Überlappende Unterlager bitte durch den gemeinsamen Bereich ersetzen."); + } + if(enabledChanged){if(g.enabled())rootIds.add(g.root());else rootIds.remove(g.root());} + // Explicit root/root overrides PHP's fallback to a different home-HQ restriction. + if(changed)groups.put(g.root(),selected.isEmpty()?Set.of(g.root()):selected); + } + if(c.readOnly()!=t.readOnly()&&!flags(s,a)||c.articles()!=t.articles()&&!s.access().root()&&!EmployeeAdministration.bit(text(s.access().actor(),"emp_rights"),14))throw EmployeeAdministration.denied(); + var values=new LinkedHashMap();values.put(ROOT,rootIds.isEmpty()?"0":csv(rootIds));values.put(SUB,groups.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->e.getKey()+","+csv(e.getValue())).collect(java.util.stream.Collectors.joining("|")));values.put(READ,c.readOnly()?"1":"0");values.put(ARTICLE,c.articles()?"1":"0"); + for(var entry:values.entrySet()){ + var existing=s.targetRows().stream().filter(r->id(r,"hq_id")==s.targetHeadquarters()&&text(r,"par_key").equals(entry.getKey())).findFirst(); + if(existing.isPresent())db.update("UPDATE parameter SET par_value=? WHERE par_id=?",entry.getValue(),id(existing.get(),"par_id"));else db.insert("parameter",fields("par_key",entry.getKey(),"par_value",entry.getValue(),"par_text","","emp_id",employee,"hq_id",s.targetHeadquarters(),"md_id",s.mandator())); + } + support.auditEmployee(u,employee,203,"STOCK_RIGHTS_BEFORE="+support.encode(t)+" | STOCK_RIGHTS_AFTER="+support.encode(new Settings(rootIds,groups,c.readOnly(),c.articles(),t.locked())),c.requestId());support.receipt(u,c.requestId(),intent,employee);return fields("completed",true); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EntryCostCenterSearch.java b/backend/src/main/java/de/stadtbote/votian/EntryCostCenterSearch.java new file mode 100644 index 0000000..732a1c1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EntryCostCenterSearch.java @@ -0,0 +1,31 @@ +package de.stadtbote.votian; +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; + +@RestController +class EntryCostCenterSearch { + record Query(String q,boolean prefix,Map filters,String sort,boolean descending,int offset,boolean payer){} + static final Map FIELDS=Map.ofEntries(Map.entry("cmp_comp","cmp.cmp_comp"),Map.entry("cmp_comp2","cmp.cmp_comp2"),Map.entry("cs_eid","cs.cs_eid"),Map.entry("csc_name","cc.csc_name"),Map.entry("cmp_match","cmp.cmp_match"),Map.entry("ad_street","ad.ad_street"),Map.entry("cscad_hsno","ca.cscad_hsno"),Map.entry("ad_zipcode","ad.ad_zipcode"),Map.entry("ad_city","ad.ad_city"),Map.entry("cscad_remark","ca.cscad_remark"),Map.entry("cscad_person","ca.cscad_person"),Map.entry("cscad_phone","ca.cscad_phone")); + static final String FROM=" FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=4 LEFT JOIN address ad ON ad.ad_id=ca.ad_id WHERE cs.hq_id=? AND cc.csc_visible=1 AND COALESCE(cc.csc_blocked,0)=0 AND cmp.cmp_visible=1 AND cmp.cmp_authenticated=1 AND COALESCE(cmp.cmp_archived,0)=0"; + final DatabaseService db; + EntryCostCenterSearch(DatabaseService db){this.db=db;} + @PostMapping("/api/orders/entry/cost-centers/search") Object query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + static String term(String s,boolean prefix){if(s==null)return prefix?"%":"%%";s=s.strip();if(s.length()>200||s.chars().anyMatch(Character::isISOControl))throw bad("Suchtext ist zu lang oder ungültig.");return (prefix?"":"%")+s.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} + Map query(LegacyUser u,Query q){u.require(7);if(q==null||q.offset()<0||q.offset()>1000000||q.filters()==null||q.filters().size()>FIELDS.size()||!FIELDS.keySet().containsAll(q.filters().keySet())||q.sort()==null||!FIELDS.containsKey(q.sort()))throw bad("Ungültige Kostenstellensuche."); + return db.readTransaction(()->{ + var layout=EmployeeCostCenterColumns.runtime(db,u);var sql=new StringBuilder(FROM);var args=new ArrayList(List.of(u.headquartersId()));if(q.payer())sql.append(" AND cc.csc_is_extern=0"); + if(q.q()!=null&&!q.q().isBlank()){sql.append(" AND (");var parts=new ArrayList();for(String field:List.of("cmp_comp","cmp_comp2","cs_eid","csc_name","cmp_match","cscad_phone")){parts.add(FIELDS.get(field)+" LIKE ? ESCAPE '!'");args.add(term(q.q(),q.prefix()));}parts.add("CAST(cc.csc_id AS CHAR)=?");args.add(q.q().strip());sql.append(String.join(" OR ",parts)).append(')');} + for(String key:new TreeSet<>(q.filters().keySet())){String v=q.filters().get(key);if(v!=null&&!v.isBlank()){sql.append(" AND ").append(FIELDS.get(key)).append(" LIKE ? ESCAPE '!'");args.add(term(v,q.prefix()));}} + long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*)"+sql,Long.class,args.toArray())).orElse(0L); + var select=new ArrayList();FIELDS.forEach((key,column)->select.add(column+" AS "+key));args.add(q.offset()); + var rows=db.queryForList("SELECT cc.csc_id,cs.cs_id,cs.hq_id,cs.csc_id_payer,cs.cs_discount,cmp.tx_id,ca.ad_id,"+String.join(",",select)+sql+" ORDER BY "+FIELDS.get(q.sort())+(q.descending()?" DESC":" ASC")+",cc.csc_id,ca.ad_id,ca.cscad_hsno LIMIT 50 OFFSET ?",args.toArray()); + var items=rows.stream().map(r->{var cells=new LinkedHashMap();FIELDS.keySet().forEach(k->cells.put(k,text(r,k)));cells.put("cmp_authenticated_img","Freigegeben");return fields("value",new OrderEntryModels.CostCenter(id(r,"csc_id"),id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"),text(r,"csc_name"),nullableId(r,"csc_id_payer"),OrderEntryCatalog.number(r,"cs_discount"),nullableId(r,"tx_id"),id(r,"hq_id")),"headquartersId",id(r,"hq_id"),"cells",cells);}).toList(); + return fields("items",items,"total",total,"offset",q.offset(),"limit",50,"columns",layout.columns(),"definitions",EmployeeCostCenterColumns.definitions(),"warning",layout.warning(),"canStatistics",u.hasRight(8),"canReports",u.hasRight(0)&&u.hasRight(16)); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EntryCustomerNotices.java b/backend/src/main/java/de/stadtbote/votian/EntryCustomerNotices.java new file mode 100644 index 0000000..5434829 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EntryCustomerNotices.java @@ -0,0 +1,33 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Company remarks shown by the PHP entry form for payer and station cost centers. */ +@RestController +class EntryCustomerNotices { + record Notice(long costCenterId,long customerId,String customerNumber,String customerName,String remark){} + private final DatabaseService db; + EntryCustomerNotices(DatabaseService db){this.db=db;} + @GetMapping("/api/orders/entry/customer-notices") + List get(Authentication auth,@RequestParam List costCenterIds){return get(ApiController.user(auth),costCenterIds);} + List get(LegacyUser user,List requested){ + user.require(7); + if(requested==null||requested.size()>101||requested.stream().anyMatch(id->id==null||id<=0))throw OrderEntryCatalog.invalid("Ungültige Kostenstellen für Kundenhinweise."); + var ids=requested.stream().distinct().sorted().toList(); + if(ids.isEmpty())return List.of(); + var args=new ArrayList();args.add(user.headquartersId());args.addAll(ids); + var rows=new ArrayList<>(db.queryForList("SELECT cc.csc_id,cs.cs_id,cs.cs_eid,cmp.cmp_comp,cmp.cmp_remark "+OrderEntryCatalog.COST_CENTER_FROM+ + " AND cc.csc_id IN ("+String.join(",",Collections.nCopies(ids.size(),"?"))+") ORDER BY cc.csc_id",args.toArray())); + var present=rows.stream().map(r->id(r,"csc_id")).toList(); + for(long missing:ids)if(!present.contains(missing))rows.addAll(new OrderBillingPayers(db).configured(user,missing)); + rows.sort(Comparator.comparingLong(r->id(r,"csc_id"))); + if(!rows.stream().map(r->id(r,"csc_id")).toList().equals(ids))throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Eine Kostenstelle für die Kundenhinweise ist gesperrt, nicht freigegeben oder gehört zu einer anderen Niederlassung."); + return rows.stream().map(r->new Notice(id(r,"csc_id"),id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"),text(r,"cmp_remark"))).toList(); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EntryPayerDocuments.java b/backend/src/main/java/de/stadtbote/votian/EntryPayerDocuments.java new file mode 100644 index 0000000..4f0c939 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EntryPayerDocuments.java @@ -0,0 +1,33 @@ +package de.stadtbote.votian; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.util.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** A direct entry point to the existing customer document permissions and namespace. */ +@RestController +class EntryPayerDocuments { + private final OrderEntryCatalog catalog; + private final OrderEntryHeadquarters entry; + private final MasterDocuments documents; + EntryPayerDocuments(OrderEntryCatalog catalog,OrderEntryHeadquarters entry,MasterDocuments documents){this.catalog=catalog;this.entry=entry;this.documents=documents;} + @GetMapping("/api/administration/entry-payer-documents") + Map context(Authentication auth,@RequestParam long costCenterId,@RequestParam long headquartersId){return context(ApiController.user(auth),costCenterId,headquartersId);} + Map context(LegacyUser user,long costCenterId,long headquartersId){ + user.require(7); + if(!user.hasRight(0)||headquartersId!=user.headquartersId())return Map.of("enabled",false); + if(costCenterId<=0)throw OrderEntryCatalog.invalid("Bitte einen gültigen Zahler wählen."); + var scoped=entry.select(user,headquartersId); + var payer=catalog.payer(scoped,costCenterId); + if(payer.headquartersId()>0&&payer.headquartersId()!=headquartersId)return Map.of("enabled",false); + boolean present; + try{present=documents.hasFiles(user,MasterDocuments.Kind.customers,payer.customerId());} + catch(AccessDeniedException denied){return Map.of("enabled",false);} + if(!present)return Map.of("enabled",false); + var current=catalog.payer(entry.select(user,headquartersId),costCenterId); + if(current.customerId()!=payer.customerId()||current.headquartersId()>0&¤t.headquartersId()!=headquartersId)throw OrderEntryCatalog.invalid("Der Zahler wurde inzwischen geändert. Bitte erneut auswählen."); + return fields("enabled",true,"customerId",payer.customerId()); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/EntryPayerReports.java b/backend/src/main/java/de/stadtbote/votian/EntryPayerReports.java new file mode 100644 index 0000000..238e048 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/EntryPayerReports.java @@ -0,0 +1,43 @@ +package de.stadtbote.votian; + +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.LegacyMapping.id; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Uses the workspace principal, not the more permissive order-entry headquarters scope. */ +@RestController +class EntryPayerReports { + private final DatabaseService db; + private final OrderEntryCatalog catalog; + private final OrderEntryHeadquarters entry; + private final ReportAdministration reports; + EntryPayerReports(DatabaseService db,OrderEntryCatalog catalog,OrderEntryHeadquarters entry,ReportAdministration reports) { + this.db=db;this.catalog=catalog;this.entry=entry;this.reports=reports; + } + @GetMapping("/api/administration/entry-payer-reports") + Map context(Authentication auth,@RequestParam long costCenterId,@RequestParam long headquartersId) { + return context(ApiController.user(auth),costCenterId,headquartersId); + } + Map context(LegacyUser user,long costCenterId,long headquartersId) { + user.require(7); + if(!user.hasRight(0)||!user.hasRight(16))return Map.of("enabled",false); + if(costCenterId<=0||headquartersId<=0)throw OrderEntryCatalog.invalid("Bitte einen gültigen Zahler wählen."); + return db.readTransaction(()->{ + var scoped=entry.select(user,headquartersId); + if(reports.headquarters(user).stream().noneMatch(h->id(h,"id")==headquartersId))return Map.of("enabled",false); + var payer=catalog.payer(scoped,costCenterId); + if(payer.headquartersId()>0&&payer.headquartersId()!=headquartersId)return Map.of("enabled",false); + boolean visible=catalog.enabled(headquartersId,"MASK_JB_REPORT_BUTTON_ENABLED_PERMANENT"); + if(!visible) { + var args=new ArrayList(List.of(headquartersId,payer.customerId())); + String confidentiality=""; + if(!user.hasRight(10)){confidentiality=" AND (rp_confidential=0 OR usr_id=?)";args.add(user.userId());} + visible=!db.queryForList("SELECT rp_id FROM "+ReportAdministration.TABLE+" WHERE hq_id=? AND rp_objtype='cs' AND rp_objid=?"+confidentiality+" LIMIT 1",args.toArray()).isEmpty(); + } + return visible?fields("enabled",true,"customer",fields("id",payer.customerId(),"label",payer.customerNumber()+" · "+payer.customerName(),"headquartersId",headquartersId)):Map.of("enabled",false); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExportEncryption.java b/backend/src/main/java/de/stadtbote/votian/ExportEncryption.java new file mode 100644 index 0000000..5a74d34 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExportEncryption.java @@ -0,0 +1,67 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** PHP-compatible OpenPGP export. Plaintext is never staged in the shared directory. */ +class ExportEncryption { + record Policy(boolean enabled,String recipient,String extension) { + Policy {if(enabled&&(recipient==null||recipient.isBlank()||recipient.startsWith("-")||recipient.length()>200||recipient.chars().anyMatch(Character::isISOControl)||extension==null||!extension.matches("[A-Za-z0-9]{1,10}")))throw bad("Ungültige gespeicherte Exportverschlüsselung.");if(!enabled){recipient="";extension="";}} + String suffix(){return enabled?"."+extension:"";} + String filename(String name){return CustomerExports.filename(name+suffix());} + } + static final Policy PLAIN=new Policy(false,"",""); + final String executable,home; + ExportEncryption(String executable,String home){this.executable=executable;this.home=home;} + void configured(){if(executable.isBlank()||home.isBlank()||!Files.isDirectory(Path.of(home)))throw bad("Die verschlüsselte Exportablage benötigt EXPORTS_GPG_EXECUTABLE und einen eingerichteten EXPORTS_GPG_HOME.");} + Policy policy(DatabaseService db,LegacyUser u){ + var catalog=new OrderEntryCatalog(db);if(!catalog.enabled(u.headquartersId(),"ENCRYPT_EXPORTDATA"))return PLAIN; + configured();String extension=catalog.parameter(u.headquartersId(),"ENCRYPT_FILEEXTENSION").trim(); + if(!extension.matches("[A-Za-z0-9]{1,10}"))throw bad("Die Dateiendung der Exportverschlüsselung fehlt oder ist ungültig."); + var rows=db.queryForList("SELECT encryption_company.cmp_keyuser FROM headquarters encryption_hq JOIN company encryption_company ON encryption_company.cmp_id=encryption_hq.cmp_id WHERE encryption_hq.hq_id=?",u.headquartersId()); + String recipient=text(one(rows),"cmp_keyuser").trim(); + if(recipient.isBlank()||recipient.length()>200||recipient.startsWith("-")||recipient.chars().anyMatch(Character::isISOControl))throw bad("Der Verschlüsselungsempfänger der Niederlassungsfirma fehlt oder ist ungültig."); + return new Policy(true,recipient,extension); + } + void unchanged(DatabaseService db,LegacyUser u,Policy previous){if(!policy(db,u).equals(previous==null?PLAIN:previous))throw bad("Die Exportverschlüsselung wurde geändert. Bitte den offenen Vorgang verwerfen und eine neue Vorschau erstellen.");} + byte[] encrypt(byte[] input,Policy policy){ + if(!policy.enabled())return input;configured(); + // Resolve precisely one primary key before invoking encryption with its full fingerprint. + String listing=new String(run(List.of("--with-colons","--fixed-list-mode","--fingerprint","--list-keys","--",policy.recipient()),new byte[0],1_000_000),StandardCharsets.UTF_8); + String fingerprint=recipient(listing); + byte[] encrypted=run(List.of("--no-auto-key-locate","--auto-key-locate","clear","--recipient",fingerprint,"--output","-","--encrypt"),input,CustomerExportFormat.MAX_OUTPUT); + if(encrypted.length==0||Arrays.equals(input,encrypted))throw bad("Die Exportverschlüsselung hat keine verschlüsselte Datei erzeugt.");return encrypted; + } + static String recipient(String listing){ + int keys=0;boolean primary=false;String fingerprint=""; + for(String line:listing.split("\n")){ + String[] f=line.split(":",-1); + if(f[0].equals("pub")){keys++;primary=true;if(f.length<12||Set.of("r","e","d","i").contains(f[1])||!f[11].contains("E"))throw bad("Der Exportempfänger hat keinen gültigen Verschlüsselungsschlüssel.");} + else if(f[0].equals("sub"))primary=false; + else if(f[0].equals("fpr")&&primary){if(f.length>9)fingerprint=f[9];primary=false;} + } + if(keys!=1||!fingerprint.matches("(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})"))throw bad("Der Exportempfänger ist im Schlüsselbund nicht eindeutig verfügbar.");return fingerprint; + } + byte[] run(List args,byte[] input,int maximum){ + var command=new ArrayList<>(List.of(executable,"--no-options","--homedir",home,"--batch","--no-tty"));command.addAll(args); + Process process=null;ExecutorService executor=Executors.newVirtualThreadPerTaskExecutor(); + try{ + process=new ProcessBuilder(command).start();Process child=process; + var output=executor.submit(()->bounded(child.getInputStream(),maximum)); + var error=executor.submit(()->bounded(child.getErrorStream(),1_000_000)); + var writer=executor.submit(()->{try(var out=child.getOutputStream()){out.write(input);}return null;}); + if(!process.waitFor(30,TimeUnit.SECONDS))throw new IOException("timeout"); + writer.get(2,TimeUnit.SECONDS);error.get(2,TimeUnit.SECONDS);byte[] bytes=output.get(2,TimeUnit.SECONDS); + if(process.exitValue()!=0)throw new IOException("gpg failed");return bytes; + }catch(InterruptedException e){Thread.currentThread().interrupt();throw bad("Die Exportverschlüsselung wurde unterbrochen; es wurde keine Datei veröffentlicht.");} + catch(IOException|ExecutionException|TimeoutException e){throw bad("Die Exportverschlüsselung ist fehlgeschlagen. Bitte Schlüssel, Vertrauensstellung und GnuPG-Konfiguration prüfen; es wurde keine Datei veröffentlicht.");} + finally{if(process!=null&&process.isAlive())process.destroyForcibly();executor.shutdownNow();} + } + static byte[] bounded(InputStream stream,int maximum)throws IOException{try(stream){byte[] result=stream.readNBytes(maximum+1);if(result.length>maximum)throw new IOException("output limit");return result;}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExportFileStore.java b/backend/src/main/java/de/stadtbote/votian/ExportFileStore.java new file mode 100644 index 0000000..be7486c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExportFileStore.java @@ -0,0 +1,19 @@ +package de.stadtbote.votian; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** A separately rooted handle to PHP's export/download directory. */ +@Component +class ExportFileStore { + final DocumentStore store; + final ExportEncryption encryption; + @org.springframework.beans.factory.annotation.Autowired + ExportFileStore(@Value("${app.exports.mode:local}")String mode,@Value("${app.exports.root:}")String root, + @Value("${app.exports.host:}")String host,@Value("${app.exports.port:22}")int port, + @Value("${app.exports.user:}")String user,@Value("${app.exports.password:}")String password, + @Value("${app.exports.key:}")String key,@Value("${app.exports.known-hosts:}")String knownHosts, + @Value("${app.exports.gpg-executable:}")String gpg,@Value("${app.exports.gpg-home:}")String gpgHome){store=new DocumentStore(mode,root,host,port,user,password,key,knownHosts);encryption=new ExportEncryption(gpg,gpgHome);} + ExportFileStore(DocumentStore store){this(store,new ExportEncryption("",""));} + ExportFileStore(DocumentStore store,ExportEncryption encryption){this.store=store;this.encryption=encryption;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasCustomerSettings.java b/backend/src/main/java/de/stadtbote/votian/ExtrasCustomerSettings.java new file mode 100644 index 0000000..88515ca --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasCustomerSettings.java @@ -0,0 +1,121 @@ +package de.stadtbote.votian; + +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.ExtrasStatistics.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Classical photo and invoice parameters shared with the current PHP application. */ +@RestController +@RequestMapping("/api/administration/extras/customer-settings") +class ExtrasCustomerSettings { + private final DatabaseService db; + private final BusinessSupport support; + ExtrasCustomerSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + enum Type { PHOTO, SERVICE_PRICE } + enum Action { SET, REMOVE } + record Values(Integer mode,Integer minimum,Integer maximum,Boolean servicePrice){} + record View(long customer,String label,Type type,String revision,boolean configured,Values values,boolean generalServicePrice){} + record Draft(long customer,Type type,String revision,Action action,Values values){} + record Command(String requestId,Draft draft,String fingerprint){} + record Preview(String fingerprint,String customer,String before,String after){} + record State(View view,List> rows){} + static String photoKey(long id){return "MASK_TR_PHOTO_CS_"+id;} + static String rangeKey(long id){return "MASK_MIN_MAX_TR_PHOTO_CS_"+id;} + static String priceKey(long id){return "INV_PRINT_SRVPRICE_"+id;} + static List keys(Type type,long id){return type==Type.PHOTO?List.of(photoKey(id),rangeKey(id)):List.of(priceKey(id));} + static void readAccess(LegacyUser u){u.require(46);} + static void writeAccess(LegacyUser u){readAccess(u);u.require(0);u.require(2);} + @GetMapping("/{type}/{customer}") View view(Authentication auth,@PathVariable Type type,@PathVariable long customer){return view(ApiController.user(auth),type,customer);} + @PostMapping("/preview") Preview preview(Authentication auth,@RequestBody Draft draft){return preview(ApiController.user(auth),draft);} + @PostMapping Map save(Authentication auth,@RequestBody Command command){return save(ApiController.user(auth),command);} + View view(LegacyUser u,Type type,long customer){readAccess(u);if(type==null||customer<=0)throw bad("Kunde und Einstellungsart fehlen.");return db.readTransaction(()->state(u,type,customer).view());} + private State state(LegacyUser u,Type type,long customer){ + var owner=one(db.queryForList("SELECT cs_id,cs_eid,cmp_comp,cmp_comp2 FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE cs_id=? AND hq_id=?",customer,u.headquartersId())); + var names=keys(type,customer); + 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(names.size())+") ORDER BY par_key,par_id",names.toArray()); + var values=decode(type,customer,rows); + boolean general=false; + List> global=List.of(); + if(type==Type.SERVICE_PRICE){global=db.queryForList("SELECT par_id,par_value FROM parameter WHERE par_key='INV_PRINT_SRVPRICE' AND hq_id=? AND emp_id=0 ORDER BY par_id",u.headquartersId());if(global.size()>1)throw bad("Der allgemeine Servicepreis-Ausweis ist mehrdeutig.");general=!global.isEmpty()&&"1".equals(text(global.getFirst(),"par_value"));} + return new State(new View(customer,String.join(" · ",List.of(text(owner,"cs_eid"),text(owner,"cmp_comp"),text(owner,"cmp_comp2")).stream().filter(s->!s.isBlank()).toList()),type,revision(List.of(u.headquartersId(),customer,type,rows,global)),!rows.isEmpty(),values,general),rows); + } + static Values decode(Type type,long customer,List> rows){ + var values=new HashMap(); + for(var r:rows){if(id(r,"md_id")!=0||id(r,"hq_id")!=0||id(r,"emp_id")!=0)throw bad("Die Kundenvorgabe hat einen abweichenden Geltungsbereich. Bitte den Bestand prüfen.");if(values.put(text(r,"par_key"),text(r,"par_value"))!=null)throw bad("Mehrdeutige Kundenvorgabe. Bitte doppelte Parameter im Bestand klären.");} + if(type==Type.SERVICE_PRICE){String value=values.get(priceKey(customer));if(value!=null&&!Set.of("0","1").contains(value))throw bad("Ungültiger Servicepreis-Ausweis im Bestand.");return new Values(null,null,null,"1".equals(value));} + String mode=values.get(photoKey(customer)),range=values.get(rangeKey(customer)); + if(mode==null){if(range!=null)throw bad("Fotoanzahl ohne zugehörigen Fotomodus. Bitte den Bestand prüfen.");return new Values(null,null,null,null);} + if(!mode.matches("[0-3]"))throw bad("Ungültiger Fotomodus im Bestand."); + // The classic station writer uses 1|1 when a required range is missing. + if(range==null)range=mode.equals("0")?"0|0":"1|1"; + if(!range.matches("[0-5]\\|[0-5]"))throw bad("Ungültige Fotoanzahl im Bestand."); + var result=new Values(Integer.valueOf(mode),range.charAt(0)-'0',range.charAt(2)-'0',null);validatePhoto(result);return result; + } + static void validatePhoto(Values v){ + if(v==null||v.mode()==null||v.minimum()==null||v.maximum()==null||v.servicePrice()!=null||v.mode()<0||v.mode()>3||v.minimum()<0||v.maximum()>5||v.minimum()>v.maximum()||(v.mode()==0?(v.minimum()!=0||v.maximum()!=0):v.maximum()<1))throw bad("Fotomodus und Fotoanzahl passen nicht zusammen. Erlaubt: mindestens 0–5, höchstens 1–5; bei gesperrten Fotos 0/0."); + } + static Draft validate(Draft d){ + if(d==null||d.type()==null||d.action()==null||d.customer()<=0||d.revision()==null||!d.revision().matches("[a-f0-9]{64}"))throw bad("Kunde, Einstellungsart oder Bestandsrevision fehlen."); + if(d.action()==Action.REMOVE){if(d.values()!=null)throw bad("Beim Entfernen dürfen keine Ersatzwerte angegeben sein.");} + else if(d.type()==Type.PHOTO)validatePhoto(d.values()); + else if(d.values()==null||!Boolean.TRUE.equals(d.values().servicePrice())||d.values().mode()!=null||d.values().minimum()!=null||d.values().maximum()!=null)throw bad("Ungültiger Servicepreis-Ausweis."); + return d; + } + static String describe(Type type,boolean configured,Values v){ + if(!configured)return "Keine Kundenvorgabe"; + if(type==Type.SERVICE_PRICE)return Boolean.TRUE.equals(v.servicePrice())?"Servicepreis gesondert ausweisen":"Kundenvorgabe inaktiv (0)"; + return switch(v.mode()){case 0->"Fotos gesperrt";case 1->"Zentralenerfassung";case 2->"Kundenerfassung";default->"Zentralen- und Kundenerfassung";}+" · "+v.minimum()+"–"+v.maximum()+" Fotos"; + } + Preview preview(LegacyUser u,Draft input){writeAccess(u);var d=validate(input);return db.readTransaction(()->prepare(u,d,state(u,d.type(),d.customer())));} + private Preview prepare(LegacyUser u,Draft d,State state){ + if(!state.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Kundenvorgabe wurde zwischenzeitlich geändert. Bitte neu laden."); + String after=describe(d.type(),d.action()==Action.SET,d.values()); + if(d.type()==Type.SERVICE_PRICE&&state.view().generalServicePrice())after+="; allgemeiner Ausweis der Niederlassung bleibt aktiv"; + return new Preview(revision(List.of(u.headquartersId(),d,state.view().revision())),state.view().label(),describe(d.type(),state.view().configured(),state.view().values()),after); + } + Map save(LegacyUser u,Command c){ + writeAccess(u);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft()); + if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst in der Vorschau prüfen."); + var receipt=List.of("EXTRAS_CUSTOMER_SETTINGS",d,c.fingerprint()); + return db.customerParameterWriteTransaction(()->{ + // Recheck the owner even for receipt replays. + one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",d.customer(),u.headquartersId())); + var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true); + var state=state(u,d.type(),d.customer());var p=prepare(u,d,state); + if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau ist veraltet. Bitte neu prüfen."); + var desired=new LinkedHashMap(); + if(d.action()==Action.SET){if(d.type()==Type.PHOTO){desired.put(photoKey(d.customer()),d.values().mode().toString());desired.put(rangeKey(d.customer()),d.values().minimum()+"|"+d.values().maximum());}else desired.put(priceKey(d.customer()),"1");} + int changed=0; + for(String key:keys(d.type(),d.customer())){ + var old=state.rows().stream().filter(r->key.equals(text(r,"par_key"))).findFirst().orElse(null);String value=desired.get(key); + if(value==null&&old!=null){db.update("DELETE FROM parameter WHERE par_id=?",id(old,"par_id"));changed++;} + else if(value!=null&&old==null){db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",""));changed++;} + else if(value!=null&&!value.equals(text(old,"par_value"))){db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id"));changed++;} + } + if(changed>0)support.audit(u,0,d.customer(),0,203,(d.type()==Type.PHOTO?"Beweisfoto-Vorgabe: ":"Servicepreis-Ausweis: ")+p.before()+" → "+p.after(),c.requestId()); + support.receipt(u,c.requestId(),receipt,changed>0?1:0); + return fields("changed",changed>0?1:0,"repeated",false); + }); + } + static ExtrasOverviews.Table list(DatabaseService db,LegacyUser u,Type type){ + readAccess(u); + var rowsByCustomer=new LinkedHashMap>(); + var parameters=new HashMap>>(); + // Start with the small parameter subset. A correlated EXISTS scanning parameters + // for every customer takes minutes on the legacy database (no schema changes needed). + for(String prefix:type==Type.PHOTO?List.of("MASK_TR_PHOTO_CS_","MASK_MIN_MAX_TR_PHOTO_CS_"):List.of("INV_PRINT_SRVPRICE_")){ + var found=db.queryForList("SELECT cs.cs_id AS customerId,cs.cs_eid AS customer,c.cmp_comp AS name,c.cmp_comp2 AS name2,CASE c.cmp_visible WHEN 1 THEN 'nein' ELSE 'ja' END AS hidden,p.par_id,p.par_key,p.md_id,p.hq_id,p.emp_id,p.par_value FROM parameter p STRAIGHT_JOIN customer cs ON cs.cs_id=CAST(SUBSTRING(p.par_key,?) AS UNSIGNED) STRAIGHT_JOIN company c ON c.cmp_id=cs.cmp_id WHERE p.par_key LIKE ? ESCAPE '!' AND p.par_key=CONCAT(?,cs.cs_id) AND cs.hq_id=? ORDER BY p.par_id LIMIT 10001",prefix.length()+1,prefix.replace("_","!_")+"%",prefix,u.headquartersId()); + if(found.size()>10000)throw bad("Zu viele Kundenvorgaben. Bitte den Parameterbestand prüfen."); + for(var row:found){long customer=id(row,"customerId");rowsByCustomer.putIfAbsent(customer,new LinkedHashMap<>(row));parameters.computeIfAbsent(customer,k->new ArrayList<>()).add(row);} + } + var rows=new ArrayList<>(rowsByCustomer.values());bounded(rows); + rows.sort(Comparator.comparing((Map r)->text(r,"customer")).thenComparingLong(r->id(r,"customerId"))); + for(var row:rows){long customer=id(row,"customerId");try{var v=decode(type,customer,parameters.get(customer));row.put("setting",describe(type,true,v));}catch(org.springframework.web.server.ResponseStatusException e){row.put("setting","Bestand prüfen: "+e.getReason());}} + return new ExtrasOverviews.Table(List.of(col("customerId","Kunden-ID","internal"),col("customer","Kundennr."),col("name","Name"),col("name2","Name 2"),col("hidden","Ausgeblendet"),col("setting","Kundenvorgabe")),rows); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasMailSettings.java b/backend/src/main/java/de/stadtbote/votian/ExtrasMailSettings.java new file mode 100644 index 0000000..30aba66 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasMailSettings.java @@ -0,0 +1,101 @@ +package de.stadtbote.votian; + +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.ExtrasStatistics.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Mail distribution overview and the four global lead-time parameters from PHP mode 13. */ +@RestController +@RequestMapping("/api/administration/extras/lead-time") +class ExtrasMailSettings { + private final DatabaseService db; + private final BusinessSupport support; + ExtrasMailSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + static final String PREFIX="MASK_JOBLIST_EXTRA_TRAVELTIME_"; + static final List SUFFIXES=List.of("MON2FRI_BEGIN","MON2FRI_END","MON2FRI_MINUTES","SAT2SUN_MINUTES"); + static final List KEYS=SUFFIXES.stream().map(k->PREFIX+k).toList(); + static final List LABELS=List.of("Startzeit Montag–Freitag","Endzeit Montag–Freitag","Aufschlag Montag–Freitag","Aufschlag Samstag/Sonntag"); + record Values(String begin,String end,String weekdayMinutes,String weekendMinutes){List list(){return Arrays.asList(begin,end,weekdayMinutes,weekendMinutes);}} + record View(String revision,Values values,List warnings,boolean editable){} + record Draft(String revision,Values values){} + record Command(String requestId,Draft draft,String fingerprint){} + record Preview(String fingerprint,String before,String after,String scope){} + record State(View view,List> rows){} + static void readAccess(LegacyUser u){u.require(46);} + static void writeAccess(LegacyUser u){readAccess(u);u.require(2);} + @GetMapping View view(Authentication auth){return view(ApiController.user(auth));} + @PostMapping("/preview") Preview preview(Authentication auth,@RequestBody Draft draft){return preview(ApiController.user(auth),draft);} + @PostMapping Map save(Authentication auth,@RequestBody Command command){return save(ApiController.user(auth),command);} + View view(LegacyUser u){readAccess(u);return db.readTransaction(()->state().view());} + private State state(){return decode(db.queryForList("SELECT par_id,par_key,md_id,hq_id,emp_id,par_value,par_text FROM parameter WHERE par_key IN (?,?,?,?) ORDER BY par_key,par_id",KEYS.toArray()));} + static State decode(List> rows){ + var warnings=new ArrayList();var values=new ArrayList();boolean editable=true; + for(int i=0;i<4;i++){String key=KEYS.get(i),label=LABELS.get(i);var matches=rows.stream().filter(r->key.equals(text(r,"par_key"))).toList();String value=matches.isEmpty()?"":text(matches.getFirst(),"par_value");values.add(value); + if(matches.isEmpty())warnings.add(label+": noch nicht hinterlegt."); + if(matches.size()>1){warnings.add(label+": mehrere Parameter vorhanden. Bestand zuerst klären.");editable=false;} + if(matches.stream().anyMatch(r->id(r,"md_id")!=0||id(r,"hq_id")!=0||id(r,"emp_id")!=0)){warnings.add(label+": abweichender Geltungsbereich. Bestand zuerst klären.");editable=false;} + if(!matches.isEmpty()&&!validValue(i,value))warnings.add(label+": ungültiger Bestandswert „"+value+"“. Bitte korrigieren."); + } + return new State(new View(revision(rows),new Values(values.get(0),values.get(1),values.get(2),values.get(3)),warnings,editable),rows); + } + static boolean validValue(int index,String value){if(value==null)return false;return index<2?value.matches(index==1?"(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00":"(?:[01][0-9]|2[0-3]):[0-5][0-9]"):value.matches("[0-9]{1,3}");} + static Draft validate(Draft d){ + if(d==null||d.revision()==null||!d.revision().matches("[a-f0-9]{64}")||d.values()==null)throw bad("Vorgaben oder Bestandsrevision fehlen."); + var values=d.values().list();for(int i=0;i<4;i++)if(!validValue(i,values.get(i)))throw bad(LABELS.get(i)+": "+(i<2?"Uhrzeit als HH:mm eingeben; 24:00 ist nur als Endzeit erlaubt.":"Bitte 0 bis 999 ganze Minuten eingeben.")); + return d; + } + static String describe(Values v){return "Montag–Freitag: "+v.begin()+"–"+v.end()+" Uhr, +"+v.weekdayMinutes()+" Minuten · Samstag/Sonntag ganztägig: +"+v.weekendMinutes()+" Minuten";} + Preview preview(LegacyUser u,Draft d){writeAccess(u);validate(d);return db.readTransaction(()->prepare(u,d,state()));} + private Preview prepare(LegacyUser u,Draft d,State s){ + if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die globalen Vorlaufzeit-Vorgaben wurden zwischenzeitlich geändert. Bitte neu laden."); + if(!s.view().editable())throw bad("Die globalen Vorgaben sind mehrdeutig oder abweichend zugeordnet. Bitte den Parameterbestand zuerst klären."); + return new Preview(revision(List.of(u.headquartersId(),d,s.view().revision())),describe(s.view().values()),describe(d.values()),"Diese Änderung gilt für alle Niederlassungen."); + } + Map save(LegacyUser u,Command c){ + writeAccess(u);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst in der Vorschau prüfen."); + var receipt=List.of("EXTRAS_LEAD_TIME",d,c.fingerprint()); + return db.globalParameterWriteTransaction(()->{ + var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true); + var s=state();var p=prepare(u,d,s);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau ist veraltet. Bitte neu prüfen.");int changed=0; + for(int i=0;i<4;i++){String key=KEYS.get(i),value=d.values().list().get(i);var old=s.rows().stream().filter(r->key.equals(text(r,"par_key"))).findFirst().orElse(null); + if(old==null){db.insert("parameter",fields("par_key",key,"md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",LABELS.get(i)));changed++;} + else if(!value.equals(text(old,"par_value"))){db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id"));changed++;} + } + if(changed>0)support.audit(u,0,0,0,203,"Globale Vorlaufzeit-Zuschläge (alle Niederlassungen): "+p.before()+" → "+p.after(),c.requestId()); + support.receipt(u,c.requestId(),receipt,changed>0?1:0);return fields("changed",changed>0?1:0,"repeated",false); + }); + } + static ExtrasOverviews.Table mailList(DatabaseService db,LegacyUser u){ + readAccess(u); + var rows=db.queryForList("SELECT par_id,par_key,par_text,par_value,md_id,hq_id,emp_id FROM parameter WHERE par_key LIKE 'MAIL!_%' ESCAPE '!' AND (par_value LIKE '%@%' OR par_value='') AND hq_id IN (0,?) ORDER BY par_key,hq_id DESC,par_id LIMIT 5001",u.headquartersId());bounded(rows); + var hq=db.queryForList("SELECT hq_mnemonic FROM headquarters WHERE hq_id=?",u.headquartersId());String label=hq.size()==1?text(hq.getFirst(),"hq_mnemonic"):"Eigene Niederlassung"; + return mailTable(rows,u.headquartersId(),label); + } + static boolean visibleMailKey(String key){ + // Mode 13 is an address overview, never a credential or arbitrary parameter editor. + String upper=key.toUpperCase(Locale.ROOT); + return upper.startsWith("MAIL_")&&!Set.of("MAIL_BCC_STATION_ADDRESS","MAIL_TEXT_RECENSION").contains(upper)&&!upper.matches(".*(?:PASSWORD|PASSWD|PASSWORT|PWD|SECRET|TOKEN|CREDENTIAL|API_KEY|PRIVATE_KEY|SMTP_PASS|SMTP_AUTH).*?"); + } + static ExtrasOverviews.Table mailTable(List> source,long headquarters,String hqLabel){ + var grouped=new LinkedHashMap>>(); + for(var r:source){String key=text(r,"par_key"),value=text(r,"par_value");if(visibleMailKey(key)&&(id(r,"hq_id")==0||id(r,"hq_id")==headquarters)&&(value.isEmpty()||value.contains("@")))grouped.computeIfAbsent(key,k->new ArrayList<>()).add(r);} + var out=new ArrayList>(); + for(var group:grouped.entrySet()){ + var own=group.getValue().stream().filter(r->id(r,"hq_id")!=0).toList();var selected=own.isEmpty()?group.getValue():own; + for(var row:selected){if(text(row,"par_value").equals("mail-cron@assecutor.de"))continue; + String note=selected.size()>1?"Mehrere Vorgaben für denselben Schlüssel und Bereich.":""; + if(id(row,"md_id")!=0)note+=(note.isEmpty()?"":" ")+"Mandant "+id(row,"md_id")+"."; + if(id(row,"emp_id")!=0)note+=(note.isEmpty()?"":" ")+"Mitarbeiterzuordnung "+id(row,"emp_id")+"."; + String description=text(row,"par_text");if(description.isBlank())description="Mail-Vorgabe ohne Beschreibung"; + out.add(fields("key",group.getKey(),"description",description,"headquarters",id(row,"hq_id")==0?"alle":hqLabel,"recipients",text(row,"par_value"),"diagnostic",note,"local",id(row,"hq_id")!=0)); + } + } + out.sort(Comparator.comparing((Map r)->!Boolean.TRUE.equals(r.get("local"))).thenComparing(r->text(r,"description"),String.CASE_INSENSITIVE_ORDER).thenComparing(r->text(r,"key"))); + return new ExtrasOverviews.Table(List.of(col("key","Parameter","internal"),col("description","Mail"),col("headquarters","Niederlassung"),col("recipients","Empfänger / Adresse"),col("diagnostic","Bestandshinweis")),out); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasOverviews.java b/backend/src/main/java/de/stadtbote/votian/ExtrasOverviews.java new file mode 100644 index 0000000..11f9282 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasOverviews.java @@ -0,0 +1,135 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.ExtrasStatistics.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Supplemental read-only reports from statistic_finishment.php (9, 11, 15, 17). */ +final class ExtrasOverviews { + private final DatabaseService db; + private final String logTable; + ExtrasOverviews(DatabaseService db,String schema){ + if(schema==null||!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema."); + this.db=db;this.logTable="`"+schema+"`.log"; + } + record Table(List columns,List> rows){} + Table query(LegacyUser u,Query q){return switch(q.kind()){ + case COURIERS->couriers(u); + case FOREIGN_INVOICES->foreignInvoices(u); + case CUSTOMER_TEMPLATES->customerTemplates(u); + case TEMPLATE_ORDERS->templateOrders(u,q.customer()); + case TEMPLATE_USAGE->templateUsage(u,q); + default->throw AdminSupport.bad("Auswertung nicht unterstützt."); + };} + + private Table foreignInvoices(LegacyUser u){ + var columns=List.of(col("customer","Kundennr."),col("name","Name"),col("name2","Name 2"),col("taxId","USt-IdNr."),col("hidden","Ausgeblendet"),col("invoiceName","Rechnungsanschrift"),col("invoiceName2","Anschrift 2"),col("invoiceName3","Anschrift 3"),col("invoiceName4","Anschrift 4"),col("country","Land"),col("postal","PLZ"),col("city","Ort"),col("street","Straße"),col("house","Nummer")); + var rows=db.queryForList(""" + SELECT cs.cs_eid AS customer,cmp.cmp_comp AS name,cmp.cmp_comp2 AS name2,cmp.cmp_stax_idno AS taxId, + CASE cmp.cmp_visible WHEN 1 THEN 'nein' ELSE 'ja' END AS hidden, + ca.cscad_comp AS invoiceName,ca.cscad_comp2 AS invoiceName2,ca.cscad_comp3 AS invoiceName3,ca.cscad_comp4 AS invoiceName4, + a.ad_country AS country,a.ad_zipcode AS postal,a.ad_city AS city,a.ad_street AS street,ca.cscad_hsno AS house + FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id + JOIN costcenter c ON c.csc_id=cs.csc_id AND c.cs_id=cs.cs_id + JOIN costcenteraddress ca ON ca.csc_id=c.csc_id AND ca.adt_id=2 + JOIN address a ON a.ad_id=ca.ad_id + WHERE cs.hq_id=? AND a.ad_country<>'DE' + ORDER BY cs.cs_eid,cs.cs_id,ca.ad_id LIMIT 5001 + """,u.headquartersId()); + return new Table(columns,rows); + } + + private Table couriers(LegacyUser u){ + var columns=List.of(col("vehicle","TT-Nr."),col("externalId","ExtID"),col("company","Firma"),col("company2","Zusatz"),col("name","Name"),col("firstName","Vorname"),col("street","Straße"),col("house","Nummer"),col("postal","PLZ"),col("city","Ort"),col("email","E-Mail"),col("imei","IMEI-Nr."),col("mobile","Mobil-Nr. VPA/PDA"),col("invoiceEmail","E-Mail Rechnungen"),col("device","App, Betriebssystem, Modell"),col("blocked","Gesperrte Kunden"),col("preferred","Bevorzugte Kunden")); + var rows=db.queryForList(""" + SELECT cr.cr_id AS courierId,cv.crvh_sid AS vehicle,cr.cr_eid AS externalId,cmp.cmp_comp AS company,cmp.cmp_comp2 AS company2, + usr.usr_name AS name,usr.usr_firstname AS firstName,a.ad_street AS street,cmp.cmp_hsno AS house, + a.ad_zipcode AS postal,a.ad_city AS city,usr.usr_email AS email,cr.cr_imei AS imei,cr.cr_mobile_pda AS mobile, + usr.usr_inv_email AS invoiceEmail,cr.cr_device_info AS deviceInfo + FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN user usr ON usr.usr_id=cr.usr_id + JOIN couriervehicle cv ON cv.cr_id=cr.cr_id JOIN address a ON a.ad_id=cmp.ad_id + WHERE cr.hq_id=? AND cv.crvh_sid<>'' ORDER BY cv.crvh_sid,cr.cr_id,cv.crvh_id LIMIT 5001 + """,u.headquartersId()); + bounded(rows); + var ids=rows.stream().map(r->id(r,"courierId")).distinct().toList(); + var relations=new HashMap>>(); + for(int start=0;start();args.add(u.headquartersId());args.addAll(batch); + var found=db.queryForList("SELECT cc.cr_id,CAST(cc.cscr_relation AS SIGNED) AS relation,cs.cs_eid FROM customercourier cc JOIN customer cs ON cs.cs_id=cc.cs_id WHERE cs.hq_id=? AND cc.cr_id IN ("+CrmScope.marks(batch.size())+") AND cc.cscr_relation IN (0,1) ORDER BY cc.cr_id,cc.cscr_relation,cs.cs_eid,cs.cs_id",args.toArray()); + for(var r:found)relations.computeIfAbsent(id(r,"cr_id"),k->new HashMap<>()).computeIfAbsent(id(r,"relation"),k->new ArrayList<>()).add(text(r,"cs_eid")); + } + for(var row:rows){var rel=relations.getOrDefault(id(row,"courierId"),Map.of());row.put("blocked",String.join(", ",rel.getOrDefault(0L,List.of())));row.put("preferred",String.join(", ",rel.getOrDefault(1L,List.of())));row.put("device",device(text(row,"deviceInfo")));} + return new Table(columns,rows); + } + static String device(String raw){ + var values=new HashMap();for(String part:raw.split("\\|")){int at=part.indexOf('=');if(at>0)values.put(part.substring(0,at).strip(),part.substring(at+1).strip());} + return String.join(", ",List.of("app_ver","os_ver","mod").stream().map(k->values.getOrDefault(k,"")).filter(v->!v.isEmpty()).toList()); + } + + /** UNION deduplicates a tour that names the same customer as payer and cash payer. */ + private static final String TEMPLATE_CUSTOMERS=""" + SELECT j.jb_id,c.cs_id FROM job j JOIN costcenter c ON c.csc_id=j.csc_id_payer + JOIN customer cs ON cs.cs_id=c.cs_id WHERE j.hq_id=? AND cs.hq_id=? AND LENGTH(j.jb_tourname)>0 + UNION + SELECT j.jb_id,c.cs_id FROM job j JOIN costcenter c ON c.csc_id=j.csc_id_payer_cash + JOIN customer cs ON cs.cs_id=c.cs_id WHERE j.hq_id=? AND cs.hq_id=? AND LENGTH(j.jb_tourname)>0 AND j.csc_id_payer_cash<>0 + """; + private Table customerTemplates(LegacyUser u){ + var columns=List.of(col("customerId","Kunden-ID","internal"),col("customer","Kundennr."),col("name","Name"),col("name2","Name 2"),col("count","Anzahl Konserven","number")); + var rows=db.queryForList("SELECT cs.cs_id AS customerId,cs.cs_eid AS customer,cmp.cmp_comp AS name,cmp.cmp_comp2 AS name2,COUNT(*) AS count FROM ("+TEMPLATE_CUSTOMERS+") t JOIN customer cs ON cs.cs_id=t.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id GROUP BY cs.cs_id,cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2 ORDER BY cs.cs_eid,cs.cs_id LIMIT 5001",u.headquartersId(),u.headquartersId(),u.headquartersId(),u.headquartersId()); + return new Table(columns,rows); + } + + private Table templateOrders(LegacyUser u,long customer){ + var columns=List.of(col("revision","Revision","internal"),col("id","Auftragsnr."),col("name","Tourenname"),col("price","Preis","money"),col("costcenter","Zahler-Kostenstelle"),col("cashCostcenter","Abweichender Barzahler"),col("company","Abholfirma"),col("street","Startadresse"),col("postal","Start-PLZ"),col("destination","Ziel-PLZ"),col("vehicleType","Fahrzeugtyp"),col("ordered","Auftragszeit")); + var rows=db.queryForList(""" + SELECT j.jb_id AS id,j.jb_tourname AS name,j.jb_version AS version,j.csc_id_payer AS payer,j.csc_id_payer_cash AS cashPayer,j.jb_totalprice AS price,c.csc_name AS costcenter,cash.csc_name AS cashCostcenter, + t.tr_comp AS company,CONCAT_WS(' ',a.ad_street,NULLIF(t.tr_hsno,'')) AS street,a.ad_zipcode AS postal,d.ad_zipcode AS destination, + mt.mt_value AS vehicleType,j.vht_id AS vehicleCode,j.jb_ordertime AS ordered + FROM job j LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer AND EXISTS (SELECT 1 FROM customer owner WHERE owner.cs_id=c.cs_id AND owner.hq_id=j.hq_id) + LEFT JOIN costcenter cash ON cash.csc_id=j.csc_id_payer_cash AND EXISTS (SELECT 1 FROM customer owner WHERE owner.cs_id=cash.cs_id AND owner.hq_id=j.hq_id) + LEFT JOIN tour t ON t.tr_id=(SELECT MIN(t1.tr_id) FROM tour t1 WHERE t1.jb_id=j.jb_id AND t1.tr_sort=1) + LEFT JOIN address a ON a.ad_id=t.ad_id + LEFT JOIN tour dest ON dest.tr_id=(SELECT MIN(t2.tr_id) FROM tour t2 WHERE t2.jb_id=j.jb_id AND t2.tr_sort=2) + LEFT JOIN address d ON d.ad_id=dest.ad_id + LEFT JOIN (SELECT mt_sort,MIN(mt_value) AS mt_value FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort) mt ON mt.mt_sort=j.vht_id + WHERE j.hq_id=? AND LENGTH(j.jb_tourname)>0 AND (c.cs_id=? OR (j.csc_id_payer_cash<>0 AND cash.cs_id=?)) + ORDER BY j.jb_tourname,j.jb_id LIMIT 5001 + """,u.headquartersId(),customer,customer); + // Do not expose the other party's cost-centre name across headquarters boundaries. + // The selected customer is validated by ExtrasStatistics before entering this method. + // Resolve the numeric fallback in Java: old Latin-1 labels cannot be COALESCEd with a UTF-8 CAST. + for(var row:rows){ + if(row.get("vehicleType")==null)row.put("vehicleType",vehicle(id(row,"vehicleCode"))); + row.put("revision",SavedTourManagement.tourRevision(row)); + for(String key:List.of("version","payer","cashPayer","vehicleCode"))row.remove(key); + } + return new Table(columns,rows); + } + + private Table templateUsage(LegacyUser u,Query q){ + var templates=db.queryForList("SELECT j.jb_id AS id,j.jb_tourname AS name FROM job j JOIN costcenter c ON c.csc_id=j.csc_id_payer JOIN customer cs ON cs.cs_id=c.cs_id WHERE j.hq_id=? AND cs.hq_id=? AND cs.cs_id=? AND LENGTH(j.jb_tourname)>0 ORDER BY j.jb_tourname,j.jb_id LIMIT 5001",u.headquartersId(),u.headquartersId(),q.customer()); + bounded(templates); + var byName=new LinkedHashMap>(); + for(var t:templates){String name=text(t,"name");var row=byName.computeIfAbsent(name,k->fields("name",k,"orders","","count",0L));String old=text(row,"orders");row.put("orders",old+(old.isEmpty()?"":", ")+id(t,"id"));} + // Events are selected by the order's date (not log date), as in the PHP report. + // Only quoted names are recognized; unrelated or malformed log messages are not usage. + String marker="Konserve \""; + var events=db.queryForList(""" + SELECT SUBSTRING_INDEX(SUBSTRING(l.logo_description,LOCATE(?,l.logo_description)+CHAR_LENGTH(?)),'"',1) AS name,COUNT(*) AS count + FROM %s l JOIN job j ON j.jb_id=l.jb_id JOIN costcenter c ON c.csc_id=j.csc_id_payer JOIN customer cs ON cs.cs_id=c.cs_id + WHERE j.hq_id=? AND cs.hq_id=? AND cs.cs_id=? AND j.jb_ordertime>=? AND j.jb_ordertime<=? + AND l.logo_id IN (25,1,162,163) AND l.log_createtime>'2024-02-23 00:00:00' + AND LOCATE(?,l.logo_description)>0 + AND LOCATE('"',l.logo_description,LOCATE(?,l.logo_description)+CHAR_LENGTH(?))>LOCATE(?,l.logo_description)+CHAR_LENGTH(?) + GROUP BY BINARY name ORDER BY count DESC,BINARY name LIMIT 5001 + """.formatted(logTable),marker,marker,u.headquartersId(),u.headquartersId(),q.customer(),q.from().atStartOfDay(),q.to().atTime(23,59,59),marker,marker,marker,marker,marker); + bounded(events); + for(var event:events){String name=text(event,"name");byName.computeIfAbsent(name,k->fields("name",k,"orders","","count",0L)).put("count",id(event,"count"));} + var rows=new ArrayList<>(byName.values());bounded(rows); + rows.sort(Comparator.comparingLong((Map r)->id(r,"count")).reversed().thenComparing(r->text(r,"name"))); + return new Table(List.of(col("name","Tourname"),col("orders","Aktuelle Auftragsnummern"),col("count","Nutzungen","number")),rows); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java b/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java new file mode 100644 index 0000000..0496288 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasPortalSettings.java @@ -0,0 +1,160 @@ +package de.stadtbote.votian; + +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.ExtrasStatistics.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Customer mask and login list switches from the current PHP Kessel Buntes. */ +@RestController +@RequestMapping("/api/administration/extras/portal-settings") +class ExtrasPortalSettings { + private final DatabaseService db; + private final BusinessSupport support; + ExtrasPortalSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + enum Type { MASK, LIST } + record Values(Boolean dispo,Boolean cash,Boolean manual,Boolean map,Boolean places,String districtCity){} + record View(long target,long customer,String label,Type type,String revision,boolean measures,Values values,List columns){} + record Draft(long target,Type type,String revision,Values values){} + record Command(String requestId,Draft draft,String fingerprint){} + record Preview(String fingerprint,String customer,String before,String after){} + record State(Map owner,List> rows,View view){} + record Change(String key,long hq,long employee,String column,String value){} + record ListColumns(List fields,List titles,List aligns) { + boolean places(){return fields.contains("jb_tourcities");} + ListColumns toggle(boolean enabled){ + var f=new ArrayList<>(fields);var t=new ArrayList<>(titles);var a=new ArrayList<>(aligns); + for(int i=f.size()-1;i>=0;i--)if(f.get(i).equals("jb_tourcities")&&!enabled){f.remove(i);t.remove(i);a.remove(i);} + if(enabled&&!places()){f.add("jb_tourcities");t.add("Stationen");a.add("l");} + return new ListColumns(f,t,a); + } + List encoded(){return List.of(String.join(",",fields),String.join(",",titles),String.join(",",aligns));} + } + static final String MEASURES="RANKING_JB2CRVH_MEASURE_CS_IDS",DISPO="JB_DISPOINFO_ENABLED_CS_IDS"; + static final List LIST_KEYS=List.of("CUSTOMER_MASK_JOBLIST_ADD_ON_FIELDS","CUSTOMER_MASK_JOBLIST_ADD_ON_TITLES","CUSTOMER_MASK_JOBLIST_ADD_ON_ALIGNS"); + static String cash(long c){return "MASK_CUST_SET_PAYER_"+c;} + static String manual(long c){return "CUSTOMER_MANUAL_DISPO_"+c;} + static String map(long c){return "MASK_JB_MAP_VIEW_ENABLED_"+c;} + static String district(long c){return "MASK_INSERTADDRESS_DISTRICT_"+c;} + static void readAccess(LegacyUser u){u.require(46);} + static void writeAccess(LegacyUser u){readAccess(u);u.require(0);u.require(2);} + @GetMapping("/{type}/{target}") View view(Authentication auth,@PathVariable Type type,@PathVariable long target){return view(ApiController.user(auth),type,target);} + @PostMapping("/preview") Preview preview(Authentication auth,@RequestBody Draft draft){return preview(ApiController.user(auth),draft);} + @PostMapping Map save(Authentication auth,@RequestBody Command command){return save(ApiController.user(auth),command);} + View view(LegacyUser u,Type type,long target){readAccess(u);if(type==null||target<=0)throw bad("Kunde oder Login fehlt.");return db.readTransaction(()->state(u,type,target).view());} + static String ownerSql(Type type){ + String common="SELECT customer.cs_id AS customerId,customer.cs_eid AS customer,company.cmp_comp AS name,company.cmp_comp2 AS name2,customer.cs_jbedit AS entryEnabled"; + if(type==Type.MASK)return common+",customer.cs_id AS targetId FROM customer JOIN company ON company.cmp_id=customer.cmp_id WHERE customer.hq_id=? AND customer.cs_jbedit=1"; + return common+",employee.emp_id AS targetId,employee.usr_id AS userId,employee.csc_id AS costcenterId,user.hq_id AS loginHq,costcenter.csc_name AS costcenter,user.usr_account AS login FROM customer JOIN company ON company.cmp_id=customer.cmp_id JOIN costcenter ON costcenter.cs_id=customer.cs_id JOIN employee ON employee.csc_id=costcenter.csc_id JOIN user ON user.usr_id=employee.usr_id WHERE customer.hq_id=? AND user.usr_account<>''"; + } + private Map owner(LegacyUser u,Type type,long target){return one(db.queryForList(ownerSql(type)+(type==Type.MASK?" AND customer.cs_id=?":" AND employee.emp_id=?"),u.headquartersId(),target));} + private State state(LegacyUser u,Type type,long target){var owner=owner(u,type,target);long c=id(owner,"customerId");List> rows; + if(type==Type.MASK)rows=db.queryForList("SELECT * FROM parameter WHERE (par_key IN (?,?) AND hq_id=?) OR par_key IN (?,?) ORDER BY par_id",MEASURES,DISPO,u.headquartersId(),cash(c),manual(c)); + else rows=db.queryForList("SELECT * FROM parameter WHERE par_key IN (?,?) OR (par_key IN (?,?,?) AND emp_id=?) ORDER BY par_id",map(c),district(c),LIST_KEYS.get(0),LIST_KEYS.get(1),LIST_KEYS.get(2),target); + return state(u,type,owner,rows); + } + static State state(LegacyUser u,Type type,Map owner,List> rows){ + long target=id(owner,"targetId"),c=id(owner,"customerId"),home=id(owner,"loginHq");var unique=new HashSet(); + for(var r:rows){String key=text(r,"par_key");boolean hqList=key.equals(MEASURES)||key.equals(DISPO),loginList=LIST_KEYS.contains(key); + if(id(r,"md_id")!=0||id(r,"emp_id")!=(loginList?target:0)|| (hqList?id(r,"hq_id")!=u.headquartersId():loginList?id(r,"hq_id")!=0&&id(r,"hq_id")!=home:id(r,"hq_id")!=0))throw bad("Abweichender Geltungsbereich im Parameterbestand. Bitte zuerst klären."); + if(!unique.add(key+":"+id(r,"hq_id")))throw bad("Mehrdeutige Vorgabe im Parameterbestand. Bitte doppelte Einträge klären."); + } + boolean measures=false;Values v;List columns=List.of(); + if(type==Type.MASK){measures=members(value(rows,MEASURES,u.headquartersId(),"par_value")).contains(Long.toString(c));v=new Values(members(value(rows,DISPO,u.headquartersId(),"par_text")).contains(Long.toString(c)),flag(rows,cash(c)),flag(rows,manual(c)),null,null,null);} + else{var cols=effectiveColumns(rows,home);columns=cols.titles();v=new Values(null,null,null,flag(rows,map(c)),cols.places(),value(rows,district(c),0,"par_value"));} + String label=String.join(" · ",List.of(text(owner,"customer"),text(owner,"name"),text(owner,"name2"),text(owner,"login")).stream().filter(s->!s.isBlank()).toList()); + return new State(owner,rows,new View(target,c,label,type,revision(List.of(u.headquartersId(),type,owner,rows)),measures,v,columns)); + } + static String value(List> rows,String key,long hq,String column){return rows.stream().filter(r->text(r,"par_key").equals(key)&&id(r,"hq_id")==hq).map(r->text(r,column)).findFirst().orElse("");} + static boolean flag(List> rows,String key){String v=value(rows,key,0,"par_value");if(!Set.of("","0","1").contains(v))throw bad("Ungültiger Schalter im Parameterbestand.");return v.equals("1");} + static List members(String csv){var ids=new ArrayList();for(String token:csv.split(",",-1)){String v=token.strip();if(v.isEmpty())continue;if(!v.matches("[1-9][0-9]{0,17}"))throw bad("Ungültige Kundenliste im Parameterbestand.");ids.add(v);}return ids;} + static String toggleMember(String csv,long customer,boolean on){String id=Long.toString(customer);boolean has=members(csv).contains(id);if(has==on)return csv; + if(on)return csv+(csv.isEmpty()||csv.endsWith(",")?"":",")+id; + 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()); + 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."); + 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());} + static Draft validate(Draft d){ + if(d==null||d.type()==null||d.target()<=0||d.revision()==null||!d.revision().matches("[a-f0-9]{64}")||d.values()==null)throw bad("Kunde, Login, Werte oder Bestandsrevision fehlen."); + var v=d.values();if(d.type()==Type.MASK){if(v.dispo()==null||v.cash()==null||v.manual()==null||v.map()!=null||v.places()!=null||v.districtCity()!=null)throw bad("Ungültige Maskenvorgaben.");} + else if(v.dispo()!=null||v.cash()!=null||v.manual()!=null||v.map()==null||v.places()==null||v.districtCity()==null||v.districtCity().length()>200||!v.districtCity().equals(v.districtCity().strip())||v.districtCity().chars().anyMatch(Character::isISOControl))throw bad("Ungültige Listenvorgaben. Für Stadtteile bitte einen Ortsteil-unabhängigen Ortsnamen eingeben (höchstens 200 Zeichen)."); + return d; + } + static List changes(State s,Draft d,long hq){ + var out=new ArrayList();var old=s.view().values();var v=d.values();long c=s.view().customer(),target=s.view().target(),home=id(s.owner(),"loginHq"); + if(d.type()==Type.MASK){ + if(!old.dispo().equals(v.dispo()))out.add(new Change(DISPO,hq,0,"par_text",toggleMember(value(s.rows(),DISPO,hq,"par_text"),c,v.dispo()))); + if(!old.cash().equals(v.cash()))out.add(new Change(cash(c),0,0,"par_value",v.cash()?"1":null)); + if(!old.manual().equals(v.manual()))out.add(new Change(manual(c),0,0,"par_value",v.manual()?"1":null)); + }else{ + if(!old.map().equals(v.map()))out.add(new Change(map(c),0,0,"par_value",v.map()?"1":null)); + if(!old.districtCity().equals(v.districtCity()))out.add(new Change(district(c),0,0,"par_value",v.districtCity().isEmpty()?null:v.districtCity())); + if(!old.places().equals(v.places())){ + var next=effectiveColumns(s.rows(),home).toggle(v.places()); + setColumns(out,next,0,target); + // Empty global values fall back to the login's original HQ in PHP. + // Remove only the places column there as well; keep all other fallback columns. + if(next.fields().isEmpty()&&home!=0){var fallback=columns(LIST_KEYS.stream().map(k->value(s.rows(),k,home,"par_value")).toList());if(fallback.places())setColumns(out,fallback.toggle(false),home,target);} + } + } + return out; + } + static void setColumns(List out,ListColumns next,long hq,long employee){var values=next.encoded();for(int i=0;i<3;i++)out.add(new Change(LIST_KEYS.get(i),hq,employee,"par_value",next.fields().isEmpty()?null:values.get(i)));} + static List> apply(List> rows,List changes){ + var copy=new ArrayList>();rows.forEach(r->copy.add(new LinkedHashMap<>(r))); + for(var ch:changes){var row=copy.stream().filter(r->text(r,"par_key").equals(ch.key())&&id(r,"hq_id")==ch.hq()&&id(r,"emp_id")==ch.employee()).findFirst().orElse(null); + if(ch.value()==null){if(row!=null)copy.remove(row);}else if(row!=null)row.put(ch.column(),ch.value());else{row=fields("par_id",0,"par_key",ch.key(),"hq_id",ch.hq(),"md_id",0,"emp_id",ch.employee(),"par_value","","par_text","");row.put(ch.column(),ch.value());copy.add(row);}} + return copy; + } + static String describe(View view){var v=view.values();if(view.type()==Type.MASK)return "Dispo-Info: "+yes(v.dispo())+" · Barzahler: "+yes(v.cash())+" · Manuelle Disposition (gespeicherte Vorgabe): "+yes(v.manual()); + return "Kartenansicht für alle Kundenzugänge: "+yes(v.map())+" · Orte für diesen Login: "+yes(v.places())+" · Stadtteile für alle Kundenzugänge: "+(v.districtCity().isEmpty()?"nein":"bei Ort mit „"+v.districtCity()+"“")+" · Zusatzspalten: "+(view.columns().isEmpty()?"keine":String.join(", ",view.columns()));} + static String yes(boolean flag){return flag?"ja":"nein";} + Preview preview(LegacyUser u,Draft d){writeAccess(u);validate(d);return db.readTransaction(()->prepare(u,d,state(u,d.type(),d.target())));} + private Preview prepare(LegacyUser u,Draft d,State s){ + if(!s.view().revision().equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Vorgaben oder die Zuordnung wurden zwischenzeitlich geändert. Bitte neu laden."); + var next=state(u,d.type(),s.owner(),apply(s.rows(),changes(s,d,u.headquartersId()))); + return new Preview(revision(List.of(u.headquartersId(),d,s.view().revision())),s.view().label(),describe(s.view()),describe(next.view())); + } + Map save(LegacyUser u,Command c){ + writeAccess(u);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen."); + var receipt=List.of("EXTRAS_PORTAL_SETTINGS",d,c.fingerprint()); + return db.portalParameterWriteTransaction(()->{ + owner(u,d.type(),d.target());var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true); + var s=state(u,d.type(),d.target());var p=prepare(u,d,s);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau ist veraltet.");int changed=0; + for(var ch:changes(s,d,u.headquartersId())){ + var old=s.rows().stream().filter(r->text(r,"par_key").equals(ch.key())&&id(r,"hq_id")==ch.hq()&&id(r,"emp_id")==ch.employee()).findFirst().orElse(null); + if(ch.value()==null&&old!=null){db.update("DELETE FROM parameter WHERE par_id=?",id(old,"par_id"));changed++;} + else if(ch.value()!=null&&old==null){var values=fields("par_key",ch.key(),"md_id",0,"hq_id",ch.hq(),"emp_id",ch.employee(),"par_value","","par_text","");values.put(ch.column(),ch.value());db.insert("parameter",values);changed++;} + else if(ch.value()!=null&&!ch.value().equals(text(old,ch.column()))){db.update("UPDATE parameter SET "+ch.column()+"=? WHERE par_id=?",ch.value(),id(old,"par_id"));changed++;} + } + if(changed>0)support.audit(u,0,s.view().customer(),0,203,(d.type()==Type.MASK?"Kundenmaske: ":"Kundenzugang "+text(s.owner(),"login")+": ")+p.before()+" → "+p.after(),c.requestId()); + support.receipt(u,c.requestId(),receipt,changed>0?1:0);return fields("changed",changed>0?1:0,"repeated",false); + }); + } + static ExtrasOverviews.Table list(DatabaseService db,LegacyUser u,Type type){ + readAccess(u);var owners=db.queryForList(ownerSql(type)+" ORDER BY customer.cs_eid,"+(type==Type.MASK?"customer.cs_id":"costcenter.csc_name,employee.emp_id")+" LIMIT 10001",u.headquartersId());limit(owners); + // Load the small parameter subset once, never one database query per customer/login. + var pool=db.queryForList("SELECT * FROM parameter WHERE (par_key IN (?,?) AND hq_id=?) OR par_key IN (?,?,?) OR par_key LIKE 'MASK!_CUST!_SET!_PAYER!_%' ESCAPE '!' OR par_key LIKE 'CUSTOMER!_MANUAL!_DISPO!_%' ESCAPE '!' OR par_key LIKE 'MASK!_JB!_MAP!_VIEW!_ENABLED!_%' ESCAPE '!' OR par_key LIKE 'MASK!_INSERTADDRESS!_DISTRICT!_%' ESCAPE '!' ORDER BY par_id LIMIT 50001",MEASURES,DISPO,u.headquartersId(),LIST_KEYS.get(0),LIST_KEYS.get(1),LIST_KEYS.get(2)); + if(pool.size()>50000)throw bad("Zu viele Parameter. Bitte den Bestand prüfen."); + var byKey=new HashMap>>();var byEmployee=new HashMap>>(); + for(var r:pool)if(LIST_KEYS.contains(text(r,"par_key")))byEmployee.computeIfAbsent(id(r,"emp_id"),k->new ArrayList<>()).add(r);else byKey.computeIfAbsent(text(r,"par_key"),k->new ArrayList<>()).add(r); + for(var row:owners){long c=id(row,"customerId");var rs=new ArrayList>();for(var k:type==Type.MASK?List.of(MEASURES,DISPO,cash(c),manual(c)):List.of(map(c),district(c)))rs.addAll(byKey.getOrDefault(k,List.of()));if(type==Type.LIST)rs.addAll(byEmployee.getOrDefault(id(row,"targetId"),List.of()));rs.sort(Comparator.comparingLong(r->id(r,"par_id"))); + try{var view=state(u,type,row,rs).view();var v=view.values();if(type==Type.MASK){row.put("measures",yes(view.measures()));row.put("dispo",yes(v.dispo()));row.put("cash",yes(v.cash()));row.put("manual",yes(v.manual()));}else{row.put("map",yes(v.map()));row.put("places",yes(v.places()));row.put("district",v.districtCity().isEmpty()?"nein":v.districtCity());}row.put("diagnostic","");} + catch(org.springframework.web.server.ResponseStatusException e){row.put("diagnostic",e.getReason());} + } + var columns=new ArrayList<>(List.of(col("targetId","Ziel-ID","internal"),col("customerId","Kunden-ID","internal"),col("customer","Kundennr."),col("name","Name"),col("name2","Name 2"))); + if(type==Type.MASK)columns.addAll(List.of(col("measures","Lademaße (lesbar)"),col("dispo","Dispo-Info"),col("cash","Barzahler"),col("manual","Manuelle Dispo (Vorgabe)"))); + else columns.addAll(List.of(col("costcenter","Kostenstelle"),col("login","Login"),col("map","Kartenansicht"),col("places","Orte"),col("district","Stadtteile (Ort)"))); + columns.add(col("diagnostic","Bestandshinweis"));return new ExtrasOverviews.Table(columns,owners); + } + static void limit(List rows){if(rows.size()>10000)throw bad("Mehr als 10.000 Kundenmasken oder Logins. Es wird keine unvollständige Übersicht ausgegeben.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java b/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java new file mode 100644 index 0000000..d2fa18a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasPzmZones.java @@ -0,0 +1,74 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.JsonNode; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.net.URI; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.ExtrasRoutingSettings.readAccess; +import static de.stadtbote.votian.ExtrasRoutingSettings.writeAccess; +import static de.stadtbote.votian.OrderIntegrationClient.unavailable; + +/** PHP zones API uses eap=true to EXCLUDE approach prices. All public flags mean include. */ +@RestController +@RequestMapping("/api/administration/extras/pzm-zones") +class ExtrasPzmZones { + record Zone(String id,String label,boolean approach){} + record Operation(String token,boolean canResume,List changes){} + record View(String revision,List zones,List roundingExceptions,List operations){} + record Draft(String revision,Map approach){} + record Preview(String fingerprint,List changes){} + record Command(String requestId,Draft draft,String fingerprint){} + record Plan(long user,long headquarters,String endpoint,Command command,List before,List after,Preview preview){} + final DatabaseService db;final BusinessSupport support;final OrderIntegrationClient client;final String configuredUrl; + ExtrasPzmZones(DatabaseService db,BusinessSupport support,OrderIntegrationClient client,@Value("${app.routing.pzm-url:}")String configuredUrl){this.db=db;this.support=support;this.client=client;this.configuredUrl=configuredUrl;} + @GetMapping View view(Authentication a){return view(ApiController.user(a));} + @PostMapping("/preview") Preview preview(Authentication a,@RequestBody Draft d){return preview(ApiController.user(a),d);} + @PostMapping Map save(Authentication a,@RequestBody Command c){return save(ApiController.user(a),c);} + @PostMapping("/operations/{token}") Map resume(Authentication a,@PathVariable String token){return resume(ApiController.user(a),token);} + String endpoint(long hq){String base=configuredUrl;if(base.isBlank()){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key='URL_PZM' AND hq_id=0 AND emp_id=0 ORDER BY par_id");if(rows.size()!=1)throw unavailable("Die PZM-Adresse ist nicht eindeutig konfiguriert.");base=text(rows.getFirst(),"par_value");}return endpoint(base,hq);} + static String endpoint(String base,long hq){try{var uri=URI.create(base);if(hq<=0||!Set.of("http","https").contains(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||uri.getQuery()!=null||uri.getFragment()!=null||!uri.getPath().matches(".*/api/pzm/?"))throw new IllegalArgumentException();return new URI(uri.getScheme(),null,uri.getHost(),uri.getPort(),uri.getPath().replaceFirst("/api/pzm/?$","/api/zones/"+hq),null,null).toString();}catch(Exception e){throw unavailable("Die PZM-Adresse muss auf /api/pzm enden und darf keine Zugangsdaten oder Abfrageparameter enthalten.");}} + Map labels(long hq){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key='PZM_MASK_ZONES' AND hq_id=? AND emp_id=0 ORDER BY par_id",hq);if(rows.size()>1)throw bad("Mehrdeutige PZM-Zonenbezeichnungen. Bitte den Parameterbestand klären.");var names=new LinkedHashMap();if(!rows.isEmpty())for(String entry:text(rows.getFirst(),"par_value").split(";")){if(entry.isBlank())continue;String[] parts=entry.split(",",2);if(parts.length!=2)throw bad("Ungültige PZM-Zonenbezeichnungen.");if(parts[0].trim().endsWith("_km"))names.put(parts[0].trim().replaceFirst("_km$",""),parts[1].trim().replaceFirst("^KM-Preis ",""));}return names;} + 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); + } + 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));} + List> pending(long hq){return db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngpzm' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",hq);} + Plan plan(Map row){try{return support.json.readValue(text(row,"gdc_content"),Plan.class);}catch(Exception e){throw bad("Der gespeicherte PZM-Vorgang ist nicht lesbar.");}} + View view(LegacyUser u){readAccess(u);String endpoint=endpoint(u.headquartersId());var zones=zones(endpoint,u.headquartersId());var operations=pending(u.headquartersId()).stream().map(r->{var p=plan(r);return new Operation(text(r,"gdc_gen_fieldname"),u.hasRight(2)&&p.user()==u.userId(),p.preview().changes());}).toList(); + // Full customer ID, no PHP RIGHT(key,6) truncation. A disabled exception is not advertised as active. + var exceptions=db.queryForList("SELECT cs.cs_eid,c.cmp_comp,c.cmp_comp2 FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND EXISTS (SELECT 1 FROM parameter p WHERE p.par_key=CONCAT('PZM_MASK_DONT_ROUND_',cs.cs_id) AND p.hq_id=0 AND p.emp_id=0 AND p.par_value='1') ORDER BY cs.cs_eid LIMIT 5001",u.headquartersId());ExtrasStatistics.bounded(exceptions); + 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);} + 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){ + writeAccess(u);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());validate(c.draft());if(c.fingerprint()==null)throw bad("Bitte die Änderung zuerst prüfen."); + var existing=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngpzm' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.headquartersId(),c.requestId()); + if(!existing.isEmpty()){var p=plan(one(existing));if(p.user()!=u.userId()||!p.command().equals(c))throw bad("Vorgangskennung gehört zu anderen Daten oder einem anderen Bearbeiter.");return resume(u,c.requestId());} + String endpoint=endpoint(u.headquartersId());var before=zones(endpoint,u.headquartersId());var preview=prepare(u,c.draft(),endpoint,before);if(!preview.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau ist veraltet.");if(preview.changes().isEmpty())return fields("changed",0,"repeated",false); + var after=before.stream().map(z->new Zone(z.id(),z.label(),c.draft().approach().get(z.id()))).toList();var p=new Plan(u.userId(),u.headquartersId(),endpoint,c,before,after,preview); + // The immutable plan is committed BEFORE contacting the external writer. No table lock during HTTP. + db.globalParameterWriteTransaction(()->{var previous=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngpzm' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.headquartersId(),c.requestId());if(!previous.isEmpty()){if(!plan(one(previous)).equals(p))throw bad("Vorgangskennung bereits verwendet.");return null;}if(!pending(u.headquartersId()).isEmpty())throw bad("Bitte zuerst den offenen PZM-Speichervorgang fortsetzen.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngpzm","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",support.encode(p),"gdc_context","PENDING"));return null;});return resume(u,c.requestId()); + } + 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(); + var updates=new ArrayList>();for(int i=0;i choices){} + static final List VEHICLES=List.of("PKW","Kombi","Kastenwagen","Transporter","Transporter XL","LKW","LKW 12 To."); + static Field number(String key,String label,String unit,boolean optional){return new Field(key,label,unit,optional,List.of());} + static Field select(String key,String label,String... labels){var choices=new ArrayList();for(int i=0;i definitions(Type type){ + if(type==Type.PZM)return List.of(select("PZM_ROUNDTRIPKM","PZM-Modus","Last-KM","Rundtour"),select("PZM_SHORTEST","Routenberechnung","Normal","Kürzeste von drei","Absolut kürzeste"),select("PZM_MASK_ROUND","Preis-Rundung","Nicht aufrunden","Aufrunden auf volle Euro")); + var out=new ArrayList<>(List.of(number("BWV_TSZ","TSZ für Rundkilometer-Frachtvergütung","%",true),number("LONGHAUL_KM","Durchschnittlicher Mindestabstand zwischen Stationen","km",false),number("RANKING_RADIUS_KM","Schrittweite für Suchradius","km",false),number("BWV_AUSSERHALB_TSZ","TSZ für Fernaufträge außerhalb","%",true))); + for(String v:VEHICLES){out.add(number("BWV_AUSSERHALB_KM_"+v,v+" · Kilometerpreis außerhalb","EUR/km",true));out.add(number("BWV_Frachtverg_Abschlag_"+v,v+" · Frachtvergütungsabschlag","%",true));}return List.copyOf(out); + } + record View(String revision,Type type,String scope,List fields,Map values,List warnings,boolean editable){} + record Draft(Type type,String revision,Map values){} + record Change(String label,String before,String after){} + record Preview(String fingerprint,String scope,List changes){} + record Command(String requestId,Draft draft,String fingerprint){} + record State(View view,List> rows){} + private final DatabaseService db;private final BusinessSupport support; + ExtrasRoutingSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + static void readAccess(LegacyUser u){u.require(46);} + static void writeAccess(LegacyUser u){readAccess(u);u.require(2);} + @GetMapping("/{type}") View view(Authentication a,@PathVariable Type type){return view(ApiController.user(a),type);} + @PostMapping("/preview") Preview preview(Authentication a,@RequestBody Draft d){return preview(ApiController.user(a),d);} + @PostMapping Map 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()); + // 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; + for(var f:defs){var matches=rows.stream().filter(r->f.key().equals(text(r,"par_key"))&&id(r,"hq_id")==hq).toList();String value=matches.isEmpty()?"":text(matches.getFirst(),"par_value");values.put(f.key(),value); + if(matches.size()>1||matches.stream().anyMatch(r->id(r,"md_id")!=0||id(r,"emp_id")!=0)){editable=false;warnings.add(f.label()+": mehrdeutige Zuordnung. Parameterbestand zuerst klären.");} + if(matches.isEmpty())warnings.add(f.label()+": noch nicht hinterlegt."); + try{normalize(f,value);}catch(RuntimeException e){warnings.add(f.label()+": ungültiger Bestandswert „"+value+"“.");} + long overrides=rows.stream().filter(r->f.key().equals(text(r,"par_key"))&&id(r,"hq_id")!=0).count(); + if(type==Type.BWV&&overrides>0)warnings.add(f.label()+": "+overrides+" Niederlassungsvorgabe(n) bleiben erhalten und können die globale Vorgabe übersteuern."); + } + var selected=rows.stream().filter(r->id(r,"hq_id")==hq).toList(); + return new State(new View(revision(List.of(type,hq,selected)),type,type==Type.PZM?"Eigene Niederlassung ("+hq+")":"Alle Niederlassungen · globale BWV-Vorgaben",defs,values,warnings,editable),selected); + } + static String normalize(Field f,String raw){ + if(raw==null)throw bad(f.label()+": Wert fehlt.");String s=raw.strip(); + if(!f.choices().isEmpty()){if(f.choices().stream().noneMatch(c->c.value().equals(s)))throw bad(f.label()+": ungültige Auswahl.");return s;} + if(s.isEmpty()&&f.optional())return ""; + boolean integer=f.unit().equals("km"); + if(!s.matches(integer?"[0-9]{1,3}":"[0-9]{1,3}(?:[,.][0-9]{1,2})?"))throw bad(f.label()+": "+(integer?"0 bis 999 ganze Kilometer eingeben.":"Nicht negative Zahl mit höchstens zwei Nachkommastellen eingeben.")); + BigDecimal n=new BigDecimal(s.replace(',','.'));if(f.key().equals("RANKING_RADIUS_KM")&&n.signum()==0)throw bad("Der Suchradius-Schritt muss größer als 0 sein."); + if(f.key().startsWith("BWV_Frachtverg_Abschlag_")&&n.compareTo(BigDecimal.valueOf(100))>0)throw bad(f.label()+": höchstens 100 % erlaubt."); + return integer?n.toPlainString():n.setScale(2).toPlainString().replace('.',','); + } + 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 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 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."); + var changes=new ArrayList();for(var f:s.view().fields()){String old=s.view().values().get(f.key()),next=d.values().get(f.key());boolean missing=s.rows().stream().noneMatch(r->f.key().equals(text(r,"par_key"))); + if(!old.equals(next)&&!equivalent(f,old,next)||missing&&!next.isEmpty())changes.add(new Change(f.label(),display(f,old),display(f,next)));} + return new Preview(revision(List.of(u.headquartersId(),d)),s.view().scope(),changes); + } + static boolean equivalent(Field f,String a,String b){try{return normalize(f,a).equals(b);}catch(RuntimeException e){return false;}} + Preview preview(LegacyUser u,Draft input){writeAccess(u);var d=validate(input);return db.readTransaction(()->prepare(u,d,state(db,u,d.type())));} + Map save(LegacyUser u,Command c){ + writeAccess(u);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());var d=validate(c.draft());if(c.fingerprint()==null)throw bad("Bitte zuerst die Vorschau prüfen.");var receipt=List.of("EXTRAS_ROUTING",d,c.fingerprint()); + return db.globalParameterWriteTransaction(()->{var previous=support.receipt(u,c.requestId(),receipt);if(previous!=null)return fields("changed",previous,"repeated",true);var s=state(db,u,d.type());var p=prepare(u,d,s);if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau ist veraltet.");int changed=0; + for(var f:s.view().fields()){String value=d.values().get(f.key());var old=s.rows().stream().filter(r->f.key().equals(text(r,"par_key"))).findFirst().orElse(null); + if(old==null&&!value.isEmpty()){db.insert("parameter",fields("par_key",f.key(),"md_id",0,"hq_id",d.type()==Type.PZM?u.headquartersId():0,"emp_id",0,"par_value",value,"par_text",f.label()));changed++;} + else if(old!=null&&!equivalent(f,text(old,"par_value"),value)){db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(old,"par_id"));changed++;}} + if(changed>0)support.audit(u,0,0,0,203,p.scope()+": "+p.changes(),c.requestId());support.receipt(u,c.requestId(),receipt,changed);return fields("changed",changed,"repeated",false); + }); + } + static ExtrasOverviews.Table table(DatabaseService db,LegacyUser u,Type type){readAccess(u);var v=state(db,u,type).view();return new ExtrasOverviews.Table(List.of(ExtrasStatistics.col("label","Vorgabe"),ExtrasStatistics.col("value","Wert"),ExtrasStatistics.col("scope","Geltungsbereich")),v.fields().stream().>map(f->fields("label",f.label(),"value",display(f,v.values().get(f.key())),"scope",v.scope())).toList());} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasSpecialReports.java b/backend/src/main/java/de/stadtbote/votian/ExtrasSpecialReports.java new file mode 100644 index 0000000..5da7d02 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasSpecialReports.java @@ -0,0 +1,56 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.ExtrasStatistics.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Special conditions and getnow, based on the current server's statistic_finishment.php. */ +final class ExtrasSpecialReports { + private ExtrasSpecialReports(){} + static ExtrasOverviews.Table conditions(DatabaseService db,LegacyUser u){ + // Use internal customer IDs: external numbers are not necessarily unique. + String flags="SELECT x.cs_id,'calculator' AS kind FROM customerservice x JOIN service s ON s.srv_id=x.srv_id JOIN customer cs ON cs.cs_id=x.cs_id WHERE x.cs_id>0 AND cs.hq_id=? " + +"UNION SELECT s.cs_id,'calculator' FROM service s JOIN customer cs ON cs.cs_id=s.cs_id WHERE s.cs_id>0 AND cs.hq_id=? " + +"UNION SELECT x.cs_id,'servicePrice' FROM servicecustomer x JOIN service s ON s.srv_id=x.srv_id JOIN servicetype t ON t.srvt_id=x.srvt_id WHERE x.hq_id=? AND x.cs_id>0 AND s.srv_mode=0 AND x.srvc_price>0 " + +"UNION SELECT x.cs_id,'serviceCourier' FROM servicecustomer x JOIN service s ON s.srv_id=x.srv_id JOIN servicetype t ON t.srvt_id=x.srvt_id WHERE x.hq_id=? AND x.cs_id>0 AND s.srv_mode=0 AND x.srvc_cr_price>0 " + +"UNION SELECT x.cs_id,'postal' FROM serviceplzcustomer x JOIN customer cs ON cs.cs_id=x.cs_id WHERE x.hq_id=? AND cs.cs_invmode=1 AND x.srvpc_price>0 " + +"UNION SELECT x.cs_id,'area' FROM serviceplzareacustomer x JOIN customer cs ON cs.cs_id=x.cs_id WHERE x.hq_id=? AND cs.cs_invmode=2 AND x.srvpac_price>0 " + +"UNION SELECT x.cs_id,'commission' FROM customercourier x JOIN customer cs ON cs.cs_id=x.cs_id WHERE cs.hq_id=? AND x.cscr_prov>0"; + var keys=List.of("servicePrice","serviceCourier","calculator","postal","area","commission"); + String aggregates=keys.stream().map(k->"MAX(IF(kind='"+k+"','X','')) AS "+k).collect(java.util.stream.Collectors.joining(",")); + long h=u.headquartersId(); + var rows=db.queryForList("SELECT cs.cs_id AS customerId,cs.cs_eid AS customer,c.cmp_comp AS name,c.cmp_comp2 AS name2,IF(c.cmp_authenticated=1,'aktiv','gesperrt') AS status,CASE cs.cs_invmode WHEN 0 THEN 'PZM' WHEN 1 THEN 'PLZ' WHEN 2 THEN 'Bereich' ELSE 'Sonstiges' END AS billing,IF(cs.cs_markup=1,'','nein') AS markup,IF(cs.cs_markup_prov>0,cs.cs_markup_prov,NULL) AS markupRate,IF(cs.cs_discount>0,cs.cs_discount,NULL) AS discount,IF(cs.cs_fixprice_discount=1,'ja','') AS fixedDiscount,"+keys.stream().map(k->"COALESCE(f."+k+",'') AS "+k).collect(java.util.stream.Collectors.joining(","))+" FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id LEFT JOIN (SELECT cs_id,"+aggregates+" FROM ("+flags+") flags GROUP BY cs_id) f ON f.cs_id=cs.cs_id WHERE cs.hq_id=? AND cs.cs_eid<>'' AND c.cmp_visible=1 AND c.cmp_archived=0 AND (cs.cs_markup=0 OR (cs.cs_markup=1 AND cs.cs_markup_prov>0) OR cs.cs_discount>0 OR cs.cs_fixprice_discount>0 OR f.cs_id IS NOT NULL) ORDER BY cs.cs_eid,cs.cs_id LIMIT 5001",h,h,h,h,h,h,h,h); + bounded(rows); + for(int start=0;start>(); + var args=new ArrayList();args.add(h);batch.forEach(r->args.add(id(r,"customerId"))); + var rates=db.queryForList("SELECT x.cs_id,c.cr_sid,x.cscr_prov FROM customercourier x LEFT JOIN courier c ON c.cr_id=x.cr_id AND c.hq_id=? WHERE x.cscr_prov>0 AND x.cs_id IN ("+CrmScope.marks(batch.size())+") ORDER BY x.cs_id,c.cr_sid,x.cr_id,x.cscr_prov",args.toArray()); + for(var rate:rates)byId.computeIfAbsent(id(rate,"cs_id"),k->new ArrayList<>()).add((rate.get("cr_sid")==null?"Transporteur nicht verfügbar":text(rate,"cr_sid"))+" "+rate.get("cscr_prov")+"%"); + for(var row:batch)row.put("commission",String.join(", ",byId.getOrDefault(id(row,"customerId"),List.of()))); + } + return new ExtrasOverviews.Table(List.of(col("customerId","Kunden-ID","internal"),col("customer","Kundennr."),col("name","Name"),col("name2","Name2"),col("status","Freigabe"),col("billing","Abrechnungstyp"),col("markup","TSZ"),col("markupRate","TSZ %","number"),col("discount","Rabatt %","number"),col("fixedDiscount","Rabatt auf Festpreis"),col("servicePrice","Servicepreise","tariff"),col("serviceCourier","Service-Frachtvergütungen","tariff"),col("calculator","Calculator","tariff"),col("postal","PLZ","tariff"),col("area","Bereiche","tariff"),col("commission","Kurierprovision")),rows); + } + private static final DateTimeFormatter ARRIVAL=DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT); + private static final DateTimeFormatter DISPLAY=DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm"); + static LocalDateTime arrival(String value){try{return LocalDateTime.parse(value.substring(0,14),ARRIVAL).withSecond(0);}catch(DateTimeException|IndexOutOfBoundsException ex){return null;}} + static String minutes(LocalDateTime from,LocalDateTime to){if(from==null||to==null)return "";long min=Duration.between(from.withSecond(0).withNano(0),to.withSecond(0).withNano(0)).toMinutes();return (min<0?"-":"")+String.format(Locale.ROOT,"%02d:%02d",Math.abs(min)/60,Math.abs(min)%60);} + static LocalDateTime timestamp(Object value){if(value instanceof java.sql.Timestamp t)return t.toLocalDateTime();if(value instanceof LocalDateTime t)return t;return null;} + static ExtrasOverviews.Table getnow(DatabaseService db,LegacyUser u,Query q){ + var rows=db.queryForList("SELECT j.jb_id AS id,j.jb_ordertime AS ordered,j.cr_sid AS driver,t.tr_id AS station,t.tr_finishtime AS finished,a.ad_zipcode AS postal,CONCAT_WS(' ',a.ad_street,NULLIF(t.tr_hsno,'')) AS address FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer JOIN customer cs ON cs.cs_id=cc.cs_id JOIN tour t ON t.jb_id=j.jb_id JOIN address a ON a.ad_id=t.ad_id WHERE j.hq_id=? AND cs.hq_id=? AND cs.cs_id=? AND j.jb_ordertime>=? AND j.jb_ordertime<=? AND t.tr_sort=2 AND t.tr_finishtime IS NOT NULL AND j.cr_sid<>'' AND EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_id=t.tr_id AND g.gdc_obj_type='tr' AND g.gdc_gen_fieldname='arr_tour') ORDER BY DATE(t.tr_finishtime),j.cr_sid,j.jb_ordertime,t.tr_finishtime,j.jb_id,t.tr_id LIMIT 5001",u.headquartersId(),u.headquartersId(),q.customer(),q.from().atStartOfDay(),q.to().atTime(23,59,59)); + bounded(rows);var arrivals=new HashMap>(); + for(int start=0;start(Object)id(r,"station")).toArray(); + for(var r:db.queryForList("SELECT gdc_obj_id AS station,LEFT(gdc_content,14) AS arrival FROM genericdatacontainer WHERE gdc_obj_type='tr' AND gdc_gen_fieldname='arr_tour' AND gdc_obj_id IN ("+CrmScope.marks(batch.size())+")",args))arrivals.computeIfAbsent(id(r,"station"),k->new ArrayList<>()).add(text(r,"arrival")); + } + Map previous=null; + for(var row:rows){var values=arrivals.getOrDefault(id(row,"station"),List.of());var arrived=values.size()==1?arrival(values.getFirst()):null;var departed=timestamp(row.get("finished")); + var warnings=new ArrayList();if(values.size()!=1)warnings.add("Ankunft mehrfach oder nicht mehr vorhanden");else if(arrived==null)warnings.add("Ankunft ungültig");if(departed==null)warnings.add("Abfahrt ungültig"); + String driving="";if(previous!=null&&Objects.equals(previous.get("driver"),row.get("driver"))&&Objects.equals(previous.get("ordered"),row.get("ordered")))driving=minutes(timestamp(previous.get("finished")),arrived); + String service=minutes(arrived,departed);if(driving.startsWith("-")||service.startsWith("-"))warnings.add("Zeitfolge prüfen"); + var ordered=timestamp(row.get("ordered"));row.put("date",ordered==null?"":ordered.toLocalDate().toString());row.put("driving",driving);row.put("arrival",arrived==null?"":DISPLAY.format(arrived));row.put("departure",departed==null?"":DISPLAY.format(departed));row.put("service",service);row.put("note",String.join("; ",warnings));previous=row; + } + return new ExtrasOverviews.Table(List.of(col("date","Datum"),col("driver","Fahrer"),col("id","Auftragsnr."),col("postal","PLZ"),col("address","Adresse"),col("driving","Fahrzeit (h:mm)"),col("arrival","Ankunft"),col("departure","Abfahrt"),col("service","Servicezeit (h:mm)"),col("note","Hinweis")),rows); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java b/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java new file mode 100644 index 0000000..aa6460d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ExtrasStatistics.java @@ -0,0 +1,109 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.http.*; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.math.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Read-only ports of the legacy Kessel Buntes reports. */ +@RestController +@RequestMapping("/api/administration/extras") +class ExtrasStatistics { + private final DatabaseService db; + private final ExtrasOverviews overviews; + ExtrasStatistics(DatabaseService db){this(db,"phoenix_log");} + @org.springframework.beans.factory.annotation.Autowired + ExtrasStatistics(DatabaseService db,@org.springframework.beans.factory.annotation.Value("${app.history-schema:phoenix_log}") String schema){this.db=db;this.overviews=new ExtrasOverviews(db,schema);} + enum Kind { + FINISHMENT("Erledigungsstatistik","Erledigungsdatum","Abgeschlossene Aufträge mit leerem Stornostatus; Abholung und bis zu 19 Zustellungen. Die Randsekunden des Zeitraums sind wie in PHP ausgeschlossen."), + SERVICE("Servicezeiten","Erledigungsdatum","Exportkennzeichen gesetzt und Servicepreis größer als null. Diese PHP-Auswertung zeigt Preise, keine gemessenen Standzeiten; die Randsekunden sind ausgeschlossen."), + POSTAL("Ziel-PLZs","Auftragsdatum","Zustellstationen ab Position 2, nach PLZ, Auftragsmonat und Fahrzeug; abgeschlossene Aufträge mit Stornostatus leer, 0, 1 oder 3."), + POSTAL_CITY("Ziel-PLZs und -Orte","Auftragsdatum","Zustellstationen ab Position 2, zusätzlich nach Ort getrennt; abgeschlossene Aufträge mit Stornostatus leer, 0, 1 oder 3."), + PRICES("Etappenpreise","Erledigungsdatum","Exportierte PLZ- und Fixpreis-Etappen mit positivem Preis. Gleiche Preise werden zusammengefasst; keine zusätzliche Status- oder Stornoauswahl."), + GETNOW("getnow – Fahr- und Servicezeiten","Auftragsdatum","Erste Zustellung mit erfasster Ankunft und Abfahrt, unabhängig von Abschluss-, Storno- und Exportstatus. Fahrzeit ab der vorherigen Abfahrt bei gleichem Fahrer und identischer Bestellzeit. Dauern in Stunden:Minuten, auch über Mitternacht; widersprüchliche Ankünfte werden gekennzeichnet."), + SPECIAL_CONDITIONS("Sonderkonditionen","Aktueller Bestand","Sichtbare, nicht archivierte Kunden mit abweichendem Treibstoffzuschlag, Rabatt, kundenspezifischen Tarifen oder Kurierprovision. Tarifkennzeichen erfassen positive Preise einschließlich künftiger Tarifstände.",false,false), + ORIGIN("Auftragsherkunft","Auftragsdatum","Alle Auftragsstatus der eigenen Niederlassung; nur Stornostatus leer oder 0. Keine Kundeneinschränkung.",true,false), + PZM_COMPARISON("PZM-Preisvergleich","Auftragsdatum","Abgeschlossene, vollständige Aufträge aus der separaten Live-Auftragsquelle gegen aktuelle PZM-Tarife dieser Anwendung. Bis 500 Aufträge, ohne Preisänderung."), + PZM_SETTINGS("PZM-Zonen und Vorgaben","Aktueller Bestand","PZM-Modus, Routenberechnung und Rundung der eigenen Niederlassung; Anfahrtsberechnung je Zone im PZM-Dienst.",false,false), + BWV_SETTINGS("BWV-Einstellungen","Aktueller Bestand","Globale Vorgaben für Fernaufträge, Vermittlungsradius, Zuschläge und Fahrzeugtarife außerhalb.",false,false), + MAIL_SETTINGS("Mail-Verteiler & Co.","Aktueller Bestand","Mail-Adressen für Ihre Niederlassung und gemeinsame Vorgaben. Auch leere Verteiler werden angezeigt. Die globalen Vorlaufzeit-Zuschläge können separat gepflegt werden.",false,false), + CUSTOMER_MASK_SETTINGS("Kundenmasken-Erweiterungen","Aktueller Bestand","Kunden mit freigeschalteter Erfassung, einschließlich ausgeblendeter Kunden. Lademaße sind nur lesbar. Manuelle Disposition wird als Vorgabe gespeichert; sie ist im aktuellen Kundenzugang nicht aktiv.",false,false), + CUSTOMER_LIST_SETTINGS("Kundenzugang-Listenerweiterungen","Aktueller Bestand","Alle Logins der Kunden Ihrer Niederlassung. Kartenansicht und Stadtteile gelten für den gesamten Kunden, Orte nur für den gewählten Login. Bestehende Zusatzspalten bleiben erhalten.",false,false), + PHOTO_SETTINGS("Beweisfoto-Vorgaben","Aktueller Bestand","Kundenvorgaben für das klassische Beweisfoto-Verfahren. Bei aktiven Foto-Workflows gelten deren Set- und Stationsvorgaben. Einschließlich gesperrter Fotos und Vorgaben für beide Erfassungsarten.",false,false), + INVOICE_SERVICE_SETTINGS("Servicepreis-Ausweis","Aktueller Bestand","Kundenspezifischer Servicepreis-Ausweis für die bestehende PHP-Rechnungserstellung; einschließlich inaktiver Vorgaben und ausgeblendeter Kunden.",false,false), + COURIERS("Transporteursliste","Aktueller Bestand","Alle Fahrzeuge mit TT-Nummer, einschließlich ausgeblendeter Transporteure; Geräteangaben und gesperrte/bevorzugte Kunden der eigenen Niederlassung.",false,false), + FOREIGN_INVOICES("Auslandsrechnungen","Aktueller Bestand","Ausländische Rechnungsanschriften der Hauptkostenstelle, einschließlich ausgeblendeter Kunden. Wie in PHP werden alle Länderkennzeichen außer DE berücksichtigt.",false,false), + CUSTOMER_TEMPLATES("Kunden-Konserven","Aktueller Bestand","Gespeicherte Touren je Kunde, einschließlich abweichender Barzahler. Jede Tour zählt pro Kunde einmal; ohne Zeit- oder Statusfilter.",false,false), + TEMPLATE_ORDERS("Gespeicherte Touren","Aktueller Bestand","Konserven des Kunden über Zahler oder abweichenden Barzahler; auch ausgeblendete Kostenstellen und unvollständige Altaufträge bleiben sichtbar.",false,true), + RECURRING_ORDERS("Daueraufträge","Stichtag","Offene letzte Aufträge einer Serie ab dem Stichtag, einschließlich abweichender Barzahler. Kalender und Historie zeigen bestehende Aufträge und berechnete Folgetermine. Der Stichtag filtert den heutigen Bestand; er rekonstruiert keinen früheren Datenbankstand."), + TEMPLATE_USAGE("Konservennutzung","Auftragsdatum","Gezählt werden Historieneinträge zur Konservennutzung für Aufträge des Zahlers im Zeitraum. Daten erst ab 23.02.2024; ungenutzte und inzwischen entfernte Vorlagen bleiben sichtbar. Bei gleichen Namen werden alle aktuellen Auftragsnummern aufgeführt."); + final String label,basis,description;final boolean dateRequired,customerRequired; + Kind(String label,String basis,String description){this(label,basis,description,true,true);} + Kind(String label,String basis,String description,boolean dateRequired,boolean customerRequired){this.label=label;this.basis=basis;this.description=description;this.dateRequired=dateRequired;this.customerRequired=customerRequired;} + } + record Query(Kind kind,LocalDate from,LocalDate to,Long customer){} + record Column(String key,String label,String type){} + record Result(Kind kind,String label,String dateBasis,String description,Query query,String customer,List columns,List> rows){} + static Column col(String key,String label){return new Column(key,label,"text");} + static Column col(String key,String label,String type){return new Column(key,label,type);} + @GetMapping("/options") Object options(Authentication auth){return options(ApiController.user(auth),LocalDate.now(ZoneId.of("Europe/Berlin")));} + Map options(LegacyUser u,LocalDate today){u.require(46);return fields("today",today,"from",today.minusMonths(1).withDayOfMonth(1),"to",today.withDayOfMonth(1).minusDays(1),"canOpenOrders",u.hasRight(4),"canOpenCustomer",u.hasRight(0),"canOpenTariffs",u.hasRight(0)&&u.hasRight(2),"canOpenRelations",u.hasRight(1),"canManageTemplates",u.hasRight(4)&&u.hasRight(7),"canManageLeadTime",u.hasRight(2),"canManageCustomerSettings",u.hasRight(0)&&u.hasRight(2),"kinds",Arrays.stream(Kind.values()).map(k->fields("id",k.name(),"label",k.label,"dateBasis",k.basis,"description",k.description,"customerRequired",k.customerRequired,"dateRequired",k.dateRequired)).toList());} + @GetMapping("/customers") Object customers(Authentication auth,@RequestParam(defaultValue="") String q){return customers(ApiController.user(auth),q);} + List> customers(LegacyUser u,String q){u.require(46);if(q.length()>100)throw bad("Suchtext zu lang.");String term="%"+q.strip().replace("!","!!").replace("%","!%").replace("_","!_")+"%"; + return db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',cs.cs_eid,NULLIF(c.cmp_comp,''),NULLIF(c.cmp_comp2,'')) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND (cs.cs_eid LIKE ? ESCAPE '!' OR c.cmp_comp LIKE ? ESCAPE '!') ORDER BY cs.cs_eid,cs.cs_id LIMIT 50",u.headquartersId(),term,term);} + @PostMapping("/query") Result query(Authentication auth,@RequestBody Query q){return query(ApiController.user(auth),q);} + @PostMapping("/csv") ResponseEntity csv(Authentication auth,@RequestBody Query q){var result=query(ApiController.user(auth),q);return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\"votian-"+q.kind().name().toLowerCase(Locale.ROOT)+(q.kind().dateRequired?"-"+q.from()+"-"+q.to():"")+".csv\"").body(csv(result));} + static byte[] csv(Result result){StringBuilder out=new StringBuilder("\uFEFF");out.append(ApiController.csvCell("Lfd. Nr."));for(var c:exportColumns(result))out.append(';').append(ApiController.csvCell(c.label()));out.append("\r\n");int n=0;for(var row:result.rows()){out.append(++n);for(var c:exportColumns(result)){Object v=row.get(c.key());String s=v==null?"":v.toString();if(v instanceof BigDecimal)s=s.replace('.',',');out.append(';').append(ApiController.csvCell(s));}out.append("\r\n");}return out.toString().getBytes(StandardCharsets.UTF_8);} + static List exportColumns(Result result){return result.columns().stream().filter(c->!c.type().equals("internal")).toList();} + Result query(LegacyUser u,Query input){u.require(46);if(input==null||input.kind()==null)throw bad("Auswertung fehlt."); + if(input.kind()==Kind.PZM_COMPARISON)throw bad("Bitte den gesonderten PZM-Preisvergleich verwenden."); + var kind=input.kind();if(kind.dateRequired)range(input.from(),input.to());if(kind.customerRequired&&(input.customer()==null||input.customer()<=0))throw bad("Bitte einen Kunden auswählen."); + var q=new Query(kind,kind.dateRequired?input.from():null,kind==Kind.RECURRING_ORDERS?input.from():kind.dateRequired?input.to():null,kind.customerRequired?input.customer():null); + return db.readTransaction(()->{ + String label=q.kind()==Kind.BWV_SETTINGS?"Globale Vorgaben":q.kind()==Kind.PZM_SETTINGS?"Eigene Niederlassung":q.kind()==Kind.MAIL_SETTINGS?"Eigene Niederlassung und globale Vorgaben":q.kind()==Kind.COURIERS?"Alle Transporteure der eigenen Niederlassung":"Alle Kunden der eigenen Niederlassung"; + if(q.kind().customerRequired)label=text(one(db.queryForList("SELECT CONCAT_WS(' · ',cs.cs_eid,NULLIF(c.cmp_comp,''),NULLIF(c.cmp_comp2,'')) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=?",q.customer(),u.headquartersId())),"label"); + List columns;List> rows; + String joins=" FROM job j JOIN costcenter c ON c.csc_id=j.csc_id_payer JOIN customer cs ON cs.cs_id=c.cs_id "; + String scope="j.hq_id=? AND cs.hq_id=? AND cs.cs_id=?"; + Object[] args={u.headquartersId(),u.headquartersId(),q.customer(),q.from()==null?null:q.from().atStartOfDay(),q.to()==null?null:q.to().atTime(23,59,59)}; + switch(q.kind()){ + case FINISHMENT->{columns=finishColumns();rows=db.queryForList("SELECT j.jb_id AS id,j.cr_sid AS vehicle,j.vht_id AS vehicleType,j.jb_reserv AS reservation,j.jb_ordertime AS ordered"+joins+" WHERE "+scope+" AND j.jb_status=2 AND j.jb_storno IS NULL AND j.vht_id IS NOT NULL AND j.jb_finishtime>? AND j.jb_finishtime{columns=List.of(col("id","Auftragsnr"),col("ordered","Datum"),col("service","Servicepreis","money"),col("price","Auftragspreis","money"),col("vehicleType","Fahrzeugtyp"));rows=db.queryForList("SELECT j.jb_id AS id,j.jb_ordertime AS ordered,ROUND(j.jb_serviceprice,2) AS service,ROUND(j.jb_totalprice,2) AS price,m.mt_value AS vehicleType"+joins+" JOIN (SELECT mt_sort,MIN(mt_value) AS mt_value FROM metatype WHERE mt_type='vehicletype' GROUP BY mt_sort) m ON m.mt_sort=j.vht_id WHERE "+scope+" AND j.jb_export_time IS NOT NULL AND j.jb_serviceprice>0 AND j.jb_finishtime>? AND j.jb_finishtime{boolean city=q.kind()==Kind.POSTAL_CITY;columns=new ArrayList<>(List.of(col("customer","Zahler"),col("postal","PLZ")));if(city)columns.add(col("city","Ort"));columns.addAll(List.of(col("month","Monat"),col("vehicle","TT-Nummer"),col("count","Anzahl Stopps","number")));String group="cs.cs_eid,a.ad_zipcode,"+(city?"a.ad_city,":"")+"DATE_FORMAT(j.jb_ordertime,'%Y-%m'),j.cr_sid"; + rows=db.queryForList("SELECT cs.cs_eid AS customer,a.ad_zipcode AS postal,"+(city?"a.ad_city AS city,":"")+"DATE_FORMAT(j.jb_ordertime,'%Y-%m') AS month,j.cr_sid AS vehicle,COUNT(*) AS count"+joins+" JOIN tour t ON t.jb_id=j.jb_id JOIN address a ON a.ad_id=t.ad_id WHERE "+scope+" AND j.jb_status=2 AND (j.jb_storno IS NULL OR j.jb_storno IN (0,1,3)) AND t.tr_sort>1 AND j.jb_ordertime>=? AND j.jb_ordertime<=? GROUP BY "+group+" ORDER BY "+group+" LIMIT 5001",args);} + case PRICES->{columns=List.of(col("price","Etappenpreis","money"),col("postal","Anzahl PLZ","number"),col("fixed","Anzahl Fix","number"),col("count","Summe","number"));rows=db.queryForList("SELECT ROUND(t.trs_price,2) AS price,COUNT(IF(t.trs_srv_name LIKE '% nach %',1,NULL)) AS postal,COUNT(IF(t.trs_srv_name='Fixpreis',1,NULL)) AS fixed,COUNT(*) AS count"+joins+" JOIN tourservice t ON t.jb_id=j.jb_id WHERE "+scope+" AND j.jb_finishtime>=? AND j.jb_finishtime<=? AND j.jb_export_time IS NOT NULL AND j.jb_export_time<>'0000-00-00 00:00:00' AND j.jb_export_time<>'9999-12-31 23:59:59' AND t.trs_price>0 AND (t.trs_srv_name LIKE '% nach %' OR t.trs_srv_name='Fixpreis') GROUP BY t.trs_price ORDER BY t.trs_price LIMIT 5001",args);} + case SPECIAL_CONDITIONS->{var table=ExtrasSpecialReports.conditions(db,u);columns=table.columns();rows=table.rows();} + case GETNOW->{var table=ExtrasSpecialReports.getnow(db,u,q);columns=table.columns();rows=table.rows();} + case PZM_SETTINGS,BWV_SETTINGS->{var table=ExtrasRoutingSettings.table(db,u,q.kind()==Kind.PZM_SETTINGS?ExtrasRoutingSettings.Type.PZM:ExtrasRoutingSettings.Type.BWV);columns=table.columns();rows=table.rows();} + case MAIL_SETTINGS->{var table=ExtrasMailSettings.mailList(db,u);columns=table.columns();rows=table.rows();} + case CUSTOMER_MASK_SETTINGS,CUSTOMER_LIST_SETTINGS->{var table=ExtrasPortalSettings.list(db,u,q.kind()==Kind.CUSTOMER_MASK_SETTINGS?ExtrasPortalSettings.Type.MASK:ExtrasPortalSettings.Type.LIST);columns=table.columns();rows=table.rows();} + case PHOTO_SETTINGS,INVOICE_SERVICE_SETTINGS->{var table=ExtrasCustomerSettings.list(db,u,q.kind()==Kind.PHOTO_SETTINGS?ExtrasCustomerSettings.Type.PHOTO:ExtrasCustomerSettings.Type.SERVICE_PRICE);columns=table.columns();rows=table.rows();} + case RECURRING_ORDERS->{var table=RecurringOrders.list(db,u,q.customer(),q.from());columns=table.columns();rows=table.rows();} + case ORIGIN->{columns=List.of(col("origin","Herkunft"),col("count","Anzahl","number"));rows=db.queryForList("SELECT CAST(j.jb_origin AS SIGNED) AS code,COUNT(*) AS count FROM job j WHERE j.hq_id=? AND j.jb_ordertime>=? AND j.jb_ordertime<=? AND (j.jb_storno IS NULL OR j.jb_storno=0) GROUP BY j.jb_origin ORDER BY j.jb_origin LIMIT 5001",u.headquartersId(),q.from().atStartOfDay(),q.to().atTime(23,59,59));for(var r:rows)r.put("origin",origin(r.get("code")));} + default->{var table=overviews.query(u,q);columns=table.columns();rows=table.rows();} + } + if(kind==Kind.CUSTOMER_MASK_SETTINGS||kind==Kind.CUSTOMER_LIST_SETTINGS)ExtrasPortalSettings.limit(rows);else bounded(rows);var normalized=new ArrayList>();for(var row:rows){var r=new LinkedHashMap();for(var c:columns){Object v=row.get(c.key());if(v instanceof java.sql.Timestamp t)v=t.toLocalDateTime().toString().replace('T',' ');if(v instanceof LocalDateTime t)v=t.toString().replace('T',' ');r.put(c.key(),v);}normalized.add(r);} + return new Result(q.kind(),q.kind().label,q.kind().basis,q.kind().description,q,label,columns,normalized); + }); + } + static void bounded(List rows){if(rows.size()>5000)throw bad("Mehr als 5000 Ergebniszeilen. Bitte die Auswahl eingrenzen; es wird kein unvollständiges Ergebnis ausgegeben.");} + static String origin(Object value){if(!(value instanceof Number n))return "unbekannt";return switch(n.intValue()){case 0->"ohne Angabe";case 1->"Telefon";case 2->"Mail";case 3->"Schnittstelle";case 4->"KAE";case 5->"Dauerauftrag";case 6->"Sonstiges";case 7->"Listenbuchung";case 8->"Höherversicherung";default->"unbekannt";};} + static String vehicle(long type){return switch((int)type){case 0->"---";case 1->"Fahrrad";case 2->"PKW";case 3->"Kombi";case 4->"Kastenwagen";case 5->"Bus";case 6->"Großer Bus";case 7->"LKW";case 11->"LKW 12 To.";default->"unbekannt";};} + static List finishColumns(){var cs=new ArrayList<>(List.of(col("id","Auftragsnummer"),col("vehicle","TT-Nummer"),col("vehicleType","Fahrzeug angefordert"),col("reservation","Vorbestellung"),col("ordered","Bestellzeit")));for(int n=1;n<=20;n++){String suffix=n==1?"":" "+(n-1);cs.addAll(List.of(col("postal"+n,"PLZ"+suffix),col("finished"+n,n==1?"Abholung":"Erledigung"+suffix),col("elapsed"+n,n==1?"Abgeholt nach":"Erledigt nach"+suffix),col("reference"+n,"Kommission"+suffix)));}return cs;} + List> finishRows(List> rows,LegacyUser u){ + var byId=new HashMap>();for(var r:rows){r.put("truck",id(r,"vehicleType")>=7);r.put("vehicleType",vehicle(id(r,"vehicleType")));r.put("reservation",id(r,"reservation")==1?"ja":"nein");byId.put(id(r,"id"),r);} + for(int start=0;start();params.add(u.headquartersId());batch.forEach(r->params.add(id(r,"id"))); + 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; + } + 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 dae01c4..e5b9484 100644 --- a/backend/src/main/java/de/stadtbote/votian/FilterAdministration.java +++ b/backend/src/main/java/de/stadtbote/votian/FilterAdministration.java @@ -22,7 +22,7 @@ class FilterAdministration { record Operation(String token,String kind,long id,long user,long hq,String at,String beforeRevision,Map before,Map after,boolean cleanup){} void readAccess(LegacyUser u){u.require(0);}void writeAccess(LegacyUser u){readAccess(u);u.require(32);} List> rows(){return db.queryForList("SELECT "+COLUMNS+" FROM courierfilter ORDER BY crf_sort,crf_short,crf_id");} - Map options(LegacyUser u){readAccess(u);var all=rows();return fields("manage",u.hasRight(32),"items",all.stream().map(r->fields("id",id(r,"crf_id"),"type",id(r,"crf_type"),"code",text(r,"crf_short"),"name",text(r,"crf_text"),"status",id(r,"crf_status"),"sort",id(r,"crf_sort"),"description",text(r,"crf_longtext"),"revision",revision(r))).toList(),"operations",u.hasRight(32)?db.queryForList("SELECT gdc_id AS id,gdc_obj_id AS filterId,SUBSTRING(gdc_gen_fieldname,11) AS token,gdc_context AS state FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 100"):List.of());} + Map options(LegacyUser u){readAccess(u);var all=rows();return fields("manage",u.hasRight(32),"items",all.stream().map(r->fields("id",id(r,"crf_id"),"type",id(r,"crf_type"),"code",text(r,"crf_short"),"name",text(r,"crf_text"),"status",id(r,"crf_status"),"sort",id(r,"crf_sort"),"description",text(r,"crf_longtext"),"revision",revision(r))).toList(),"operations",u.hasRight(32)?db.queryForList("SELECT gdc_id AS id,gdc_obj_id AS filterId,SUBSTRING(gdc_gen_fieldname,11) AS token,gdc_context AS state FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context IN ('PENDING','CLEANUP_PENDING') ORDER BY gdc_id LIMIT 100"):List.of());} Map row(long id){return one(db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_id=?",id));} static String token(String s){try{return UUID.fromString(s).toString();}catch(Exception e){throw bad("Gültige Vorgangskennung fehlt.");}} void globalLock(){if(db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id").isEmpty())throw bad("Keine Niederlassung für die Verwaltungssperre vorhanden.");} @@ -34,7 +34,7 @@ class FilterAdministration { else {if(!db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=? OR crf_text=?",code,name).isEmpty())throw bad("Kürzel oder Bezeichnung existiert bereits.");if(reserved().stream().anyMatch(v->v.equalsIgnoreCase(code)))throw bad("Das Kürzel ist für eine laufende Löschbereinigung gesperrt.");} persist(new Operation(token,kind,id==null?0:id,u.userId(),u.headquartersId(),LocalDateTime.now().toString(),s.revision(),before,after,false));return null;});return resume(u,token); } - void rejectPending(Long id,String code){for(var r:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context='PENDING'")){Operation op=decode(r);String reservedCode=text(op.kind().equals("CREATE")?op.after():op.before(),"crf_short");if((id!=null&&op.id()==id)||reservedCode.equalsIgnoreCase(code))throw bad("Für diesen Filter besteht bereits ein gesicherter Vorgang. Bitte diesen zuerst fortsetzen.");}} + void rejectPending(Long id,String code){for(var r:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context IN ('PENDING','CLEANUP_PENDING')")){Operation op=decode(r);String reservedCode=text(op.kind().equals("CREATE")?op.after():op.before(),"crf_short");if((id!=null&&op.id()==id)||reservedCode.equalsIgnoreCase(code))throw bad("Für diesen Filter besteht bereits ein gesicherter Vorgang. Bitte diesen zuerst fortsetzen.");}} static boolean sameDesired(Map a,Map b){for(String k:List.of("crf_type","crf_short","crf_text","crf_status","crf_sort","crf_longtext"))if(!Objects.toString(a.get(k),"").equals(Objects.toString(b.get(k),"")))return false;return true;} Map remove(LegacyUser u,long id,Delete d){writeAccess(u);if(d==null)throw bad("Vorgangsdaten fehlen.");String token=token(d.requestId());db.writeTransaction(()->{globalLock();var existing=operationRow(token,false);if(existing!=null){Operation op=decode(existing);if(op.user()!=u.userId()||op.id()!=id||!op.kind().equals("DELETE"))throw bad("Vorgangskennung bereits verwendet.");return null;}var before=row(id);rejectPending(id,text(before,"crf_short"));unchanged(before,d.revision());String code=text(before,"crf_short");if(code.isBlank()||code.contains(","))throw bad("Ungültiges Kürzel im Bestand.");boolean cleanup=db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=? AND crf_id<>?",code,id).isEmpty();persist(new Operation(token,"DELETE",id,u.userId(),u.headquartersId(),LocalDateTime.now().toString(),d.revision(),before,Map.of(),cleanup));return null;});return resume(u,token);} Set reserved(){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key='FILTER_TO_BE_DELETED' AND hq_id=0 AND emp_id=0 AND md_id=0");if(rows.size()>1)throw bad("Mehrdeutige Warteschlange für Filterbereinigung.");return rows.isEmpty()?new LinkedHashSet<>():new LinkedHashSet<>(Arrays.stream(text(rows.getFirst(),"par_value").split(",")).filter(v->!v.isBlank()).toList());} @@ -42,7 +42,37 @@ class FilterAdministration { void persist(Operation op){try{String content=json.writeValueAsString(op);if(content.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Änderung ist für das Bestandsprotokoll zu groß. Langtext bitte verkürzen.");db.insert("genericdatacontainer",fields("gdc_obj_type","crf","gdc_obj_id",op.id(),"gdc_gen_fieldname",PREFIX+op.token(),"gdc_content",content,"gdc_context","PENDING"));}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);}} Operation decode(Map row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Gespeicherter Filtervorgang ist beschädigt.");}} Map operationRow(String token,boolean lock){String sql="SELECT gdc_id,gdc_obj_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='crf' AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,PREFIX+token):db.queryForList(sql,PREFIX+token);if(rows.size()>1)throw bad("Doppelte Vorgangskennung im Bestand.");return rows.isEmpty()?null:rows.getFirst();} - Map resume(LegacyUser u,String token){writeAccess(u);token(token);var saved=operationRow(token,false);if(saved==null)throw bad("Filtervorgang nicht gefunden.");try{return db.writeTransaction(()->{globalLock();var state=operationRow(token,true);if(text(state,"gdc_context").equals("DONE"))return fields("token",token,"state","DONE","message","Filtervorgang abgeschlossen.");Operation op=decode(state);execute(op);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(state,"gdc_id"));return fields("token",token,"state","DONE","message",op.kind().equals("DELETE")?"Filter entfernt. Tour- und Dauerauftragsvorlagen werden über die PHP-Bereinigungswarteschlange nachgeführt.":"Filter gespeichert.");});}catch(RuntimeException e){return fields("token",token,"state","PENDING","message","Der Vorgang ist gesichert, aber noch nicht vollständig abgeschlossen. Bitte Liste und Vorgangsstatus prüfen und den gespeicherten Vorgang fortsetzen. Keinen zweiten Vorgang anlegen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}} + Map resume(LegacyUser u,String token){ + writeAccess(u);token(token);var saved=operationRow(token,false);if(saved==null)throw bad("Filtervorgang nicht gefunden."); + try{ + Operation cleanup=db.writeTransaction(()->{globalLock();var state=operationRow(token,true);String phase=text(state,"gdc_context");if(phase.equals("DONE"))return null;Operation op=decode(state); + if(phase.equals("CLEANUP_PENDING"))return op;if(!phase.equals("PENDING"))throw bad("Unbekannter Filtervorgangsstatus.");execute(op); + boolean required=op.kind().equals("DELETE")&&op.cleanup();db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?",required?"CLEANUP_PENDING":"DONE",id(state,"gdc_id"));return required?op:null;}); + if(cleanup!=null&&!cleanupTemplates(cleanup))return fields("token",token,"state","PENDING","message","Filter entfernt; weitere Tour- und Dauerauftragsvorlagen sind zur Bereinigung vorgemerkt. Bitte denselben Vorgang fortsetzen."); + return fields("token",token,"state","DONE","message",cleanup==null?"Filtervorgang abgeschlossen.":"Filter entfernt. Tour- und offene Dauerauftragsvorlagen sind bereinigt; das Kürzel ist wieder verfügbar."); + }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);} + 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; + if(!text(state,"gdc_context").equals("CLEANUP_PENDING")||!op.kind().equals("DELETE")||!op.cleanup())throw bad("Ungültige Filterbereinigung."); + String code=text(op.before(),"crf_short");if(code.isBlank()||code.contains(","))throw bad("Ungültiges Filterkürzel."); + if(!db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=?",code).isEmpty())throw bad("Das Filterkürzel wurde zwischenzeitlich erneut vergeben. Die Vorlagenbereinigung bleibt zur Prüfung offen."); + var jobs=db.queryForList("SELECT jb_id,jb_cr_filter FROM job WHERE "+TEMPLATE_SCOPE+" AND FIND_IN_SET(?,jb_cr_filter)>0 ORDER BY jb_id LIMIT 1001",code); + for(var job:jobs.stream().limit(1000).toList()){ + String before=text(job,"jb_cr_filter"),after=withoutCode(before,code); + if(before.equals(after))throw bad("Ein Filtereintrag kann nicht eindeutig bereinigt werden."); + if(db.update("UPDATE job SET jb_cr_filter=?,jb_modify=jb_modify WHERE jb_id=? AND BINARY jb_cr_filter=BINARY ?",after,id(job,"jb_id"),before)!=1)throw bad("Eine Tourvorlage wurde während der Bereinigung geändert."); + } + if(jobs.size()>1000)return false; + var queue=db.queryForList("SELECT par_id,par_value FROM parameter WHERE par_key='FILTER_TO_BE_DELETED' AND hq_id=0 AND emp_id=0 AND md_id=0");if(queue.size()>1)throw bad("Mehrdeutige Warteschlange für Filterbereinigung."); + if(!queue.isEmpty()){var row=queue.getFirst();db.update("UPDATE parameter SET par_value=? WHERE par_id=?",withoutCode(text(row,"par_value"),code),id(row,"par_id"));} + db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(state,"gdc_id"));return true; + }); + } + void execute(Operation op){long filterId=op.id();if(op.kind().equals("CREATE")){var rows=db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_short=?",text(op.after(),"crf_short"));if(rows.isEmpty()){db.update("INSERT INTO courierfilter (crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext) SELECT ?,?,?,?,0,?,? WHERE NOT EXISTS (SELECT 1 FROM courierfilter WHERE crf_short=? OR crf_text=?)",id(op.after(),"crf_type"),text(op.after(),"crf_short"),text(op.after(),"crf_text"),id(op.after(),"crf_status"),id(op.after(),"crf_sort"),text(op.after(),"crf_longtext"),text(op.after(),"crf_short"),text(op.after(),"crf_text"));var created=one(db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_short=?",text(op.after(),"crf_short")));if(!sameDesired(created,op.after()))throw bad("Filterkürzel inzwischen anderweitig belegt.");filterId=id(created,"crf_id");}else {if(rows.size()!=1||!sameDesired(rows.getFirst(),op.after()))throw bad("Filterkürzel inzwischen anderweitig belegt.");filterId=id(rows.getFirst(),"crf_id");}} else if(op.kind().equals("UPDATE")){var current=row(op.id());if(!sameDesired(current,op.after())){unchanged(current,op.beforeRevision());var args=new ArrayList(List.of(text(op.after(),"crf_longtext"),id(op.after(),"crf_sort")));args.addAll(compareArgs(op.before()));if(db.update("UPDATE courierfilter SET crf_longtext=?,crf_sort=? WHERE "+compareClause(),args.toArray())!=1)throw bad("Filter wurde zwischenzeitlich geändert.");}} else if(op.kind().equals("DELETE")){var current=db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_id=?",op.id());if(!current.isEmpty()){unchanged(one(current),op.beforeRevision());if(db.update("DELETE FROM courierfilter WHERE "+compareClause(),compareArgs(op.before()).toArray())!=1)throw bad("Filter wurde zwischenzeitlich geändert.");}if(op.cleanup()){reserve(text(op.before(),"crf_short"));String needle=","+text(op.before(),"crf_short")+","; diff --git a/backend/src/main/java/de/stadtbote/votian/FleetAssignment.java b/backend/src/main/java/de/stadtbote/votian/FleetAssignment.java new file mode 100644 index 0000000..187e7c2 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FleetAssignment.java @@ -0,0 +1,114 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static de.stadtbote.votian.ApiController.user; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Explicit map assignment; merely viewing a map or preview never changes ranking. */ +@RestController +@RequestMapping("/api/administration/fleet-map/orders/{job}/assignment/{courier}") +public class FleetAssignment { + private final DatabaseService db; + private final OrderOperations orders; + private final OrderEntryCatalog catalog; + public FleetAssignment(DatabaseService db,OrderOperations orders,OrderEntryCatalog catalog){this.db=db;this.orders=orders;this.catalog=catalog;} + public record Command(String requestId,String orderRevision,String fingerprint,long vehicleId){} + record Preview(long job,long courier,long vehicleId,String sid,String vehicle,String previousCourier, + String orderRevision,String fingerprint,boolean loseRanking,List warnings,List blockers){} + record Prepared(Preview preview,Object evidence,Map vehicle,String rankingAfter){} + @GetMapping Preview get(Authentication auth,@PathVariable long job,@PathVariable long courier){return preview(user(auth),job,courier);} + @PostMapping Map post(Authentication auth,@PathVariable long job,@PathVariable long courier,@RequestBody Command command){return assign(user(auth),job,courier,command);} + static void access(LegacyUser u){u.require(19);u.require(4);u.require(7);} + Preview preview(LegacyUser u,long job,long courier){access(u);return db.readTransaction(()->prepare(u,job,courier,orders.snapshot(u,job,false),false).preview());} + Map assign(LegacyUser u,long job,long courier,Command c){ + access(u);if(c==null||c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}")||c.vehicleId()<=0)throw bad("Bitte zuerst die Zuweisung prüfen."); + var action=new OrderOperations.Action(c.requestId(),c.orderRevision(),"ASSIGN",c.vehicleId(),null,null,"Karte · Kurier "+courier+" · "+c.fingerprint(),false,false); + return orders.action(u,job,action,s->{ + var p=prepare(u,job,courier,s,true);unchanged(p.evidence(),c.fingerprint()); + if(p.preview().vehicleId()!=c.vehicleId())throw bad("Das angemeldete Fahrzeug hat sich geändert. Bitte erneut prüfen."); + if(!p.preview().blockers().isEmpty())throw bad(String.join(" · ",p.preview().blockers())); + return p.preview().loseRanking()?1:0; + },false,false,null,courier); + } + private Prepared prepare(LegacyUser u,long job,long courier,OrderOperations.Snapshot s,boolean lock){return prepare(u,job,courier,s,lock,0);} + Prepared prepareVehicle(LegacyUser u,long job,long vehicle,long expectedCourier,OrderOperations.Snapshot s,boolean lock){ + var v=AssignmentVehicles.resolve(db,catalog,u,vehicle,expectedCourier,true,lock);return prepare(u,job,id(v,"cr_id"),s,lock,vehicle); + } + private Prepared prepare(LegacyUser u,long job,long courier,OrderOperations.Snapshot s,boolean lock,long selectedVehicle){ + boolean fromList=selectedVehicle>0;boolean bwv=fromList&&id(s.job(),"jb_longhaul")==1; + var blockers=new ArrayList();var warnings=new ArrayList();var evidence=new ArrayList();evidence.add(u.headquartersId());evidence.add(courier);evidence.add(revision(s)); + try{orders.writable(s);}catch(ResponseStatusException e){if(e.getStatusCode().value()!=400)throw e;blockers.add(e.getReason());} + if(s.stops().isEmpty()||db.queryForObject("SELECT COUNT(*) FROM tour WHERE jb_id=?",Long.class,job)!=s.stops().size())blockers.add("Die Auftragsstationen oder ihre Adressen sind unvollständig."); + var j=s.job();long status=id(j,"jb_status"); + if(!Set.of(0L,1L,8L,9L).contains(status))blockers.add("Nur offene Aufträge können zugewiesen werden."); + if(id(j,"jb_offer")!=0)blockers.add("Ein Angebot kann nicht disponiert werden."); + if(id(j,"jb_longhaul")!=0&&!(fromList&&id(j,"jb_longhaul")==1))blockers.add(fromList?"Für diesen Fernauftrag ist die BWV-Vermittlung erforderlich.":"BWV-Fernaufträge werden nicht über die Karte zugewiesen."); + if(id(j,"hq_id_exec")>0&&id(j,"hq_id_exec")!=u.headquartersId())blockers.add("Die ausführende Niederlassung ist abweichend."); + boolean multiple=AssignmentVehicles.multiple(catalog,u);evidence.add(multiple); + String sql="SELECT cr.cr_sid,cr.cr_available,cr.cr_occupied,cr.cr_locationzipcode,cr.cr_availabletime,cr.cr_filter,cr.hq_id AS courier_hq,u.usr_country,cv.*,"+AssignmentVehicles.IDENTITY+",cmp.cmp_comp "+AssignmentVehicles.from(fromList,multiple)+(fromList?" AND cv.crvh_id=?":" AND cr.cr_id=? AND cv.crvh_sid=cr.cr_sid"); + Object[] vehicleArgs=fromList?new Object[]{selectedVehicle}:new Object[]{u.headquartersId(),fromList?selectedVehicle:courier}; + var vehicles=lock?db.lockRows(sql,vehicleArgs):db.queryForList(sql,vehicleArgs); + if(vehicles.size()!=1)throw bad("Kein eindeutig angemeldetes, freigegebenes Fahrzeug für diesen Transporteur vorhanden."); + var v=AssignmentVehicles.identity(vehicles.getFirst());if(id(v,"cr_id")!=courier||AssignmentVehicles.ambiguous(v,multiple))throw bad("Die Fahrer-/Fahrzeuganmeldung ist nicht eindeutig. Bitte die Auswahl aktualisieren.");evidence.add(v); + if(fromList&&!bwv&&id(v,"courier_hq")!=u.headquartersId())ClassicCandidates.requireForeignSelection(db,catalog,u,s,selectedVehicle,courier,evidence); + if(id(v,"cr_available")!=1)(fromList?warnings:blockers).add(fromList?"Der Transporteur ist nicht als verfügbar gemeldet.":"Der Transporteur ist nicht online angemeldet."); + if(fromList?(id(j,"cr_id")==courier&&status==1):(id(j,"cr_id")==courier||id(j,"cr_id_order")==courier))blockers.add("Der Auftrag ist diesem Transporteur bereits zugewiesen."); + if(id(v,"cr_occupied")!=0)warnings.add("Der Transporteur ist bereits als belegt gemeldet."); + if(id(j,"vht_id")!=id(v,"vht_id"))warnings.add("Abweichende Fahrzeugklasse: Auftrag "+id(j,"vht_id")+", Fahrzeug "+id(v,"vht_id")+"."); + var preference=fromList?LonghaulCandidates.preference(db,catalog,u,s,courier,evidence):new LonghaulCandidates.Preference(false,true,true); + var sizes=new ArrayList(); + for(var pair:List.of(new String[]{"jb_weight","crvh_payload","Zuladung (kg)"},new String[]{"jb_crvh_length","crvh_length","Länge (cm)"},new String[]{"jb_crvh_width","crvh_width","Breite (cm)"},new String[]{"jb_crvh_height","crvh_height","Höhe (cm)"},new String[]{"jb_crvh_position","crvh_position","Stellplätze"})){ + if(id(j,pair[0])>id(v,pair[1]))sizes.add(pair[2]+": benötigt "+id(j,pair[0])+", vorhanden "+id(v,pair[1])); + } + if(!sizes.isEmpty())("1".equals(setting(u,"RANKING_JB2CRVH_MEASURE",evidence))&&preference.measuresRequired()?blockers:warnings).add("Fahrzeugmaße/Zuladung passen nicht: "+String.join("; ",sizes)); + var vehicleFilters=tokens(text(v,"crvh_filter"));var courierFilters=tokens(text(v,"cr_filter")); + var filterTypes=db.queryForList("SELECT crf_short,crf_type FROM courierfilter WHERE crf_type IN (0,1) ORDER BY crf_short,crf_type");evidence.add(filterTypes); + for(String needed:tokens(text(j,"jb_cr_filter"))){ + var types=filterTypes.stream().filter(r->text(r,"crf_short").equals(needed)).map(r->id(r,"crf_type")).distinct().toList(); + boolean missing=types.isEmpty()?(!vehicleFilters.contains(needed)&&!courierFilters.contains(needed)):types.stream().anyMatch(t->!(t==0?vehicleFilters:courierFilters).contains(needed)); + if(missing)(types.isEmpty()||!preference.filterRequired()?warnings:blockers).add("Filterbedingung nicht erfüllt: "+needed); + } + // PHP applies blocked relations to station and payer customers according to these switches. + var centers=new TreeSet(); + if("1".equals(setting(u,"RANKING_BLOCKED_COURIER_FOR_STATION",evidence)))s.stops().forEach(t->centers.add(id(t,"csc_id"))); + if("1".equals(setting(u,"RANKING_BLOCKED_COURIER_FOR_PAYER",evidence))){ + String cash=setting(u,"CSC_ID_PAYER_CASH",evidence);long payer=id(j,"csc_id_payer"); + if(payer==-1||Long.toString(payer).equals(cash))centers.add(id(j,"csc_id_payer_cash")); + else {s.prices().forEach(p->centers.add(id(p,"csc_id")));centers.add(payer);} + } + centers.removeIf(c->c<=0); + for(long center:centers){var relations=db.queryForList("SELECT r.cs_id,r.cscr_relation FROM customercourier r JOIN costcenter c ON c.cs_id=r.cs_id WHERE c.csc_id=? AND r.cr_id=? ORDER BY r.cs_id,r.cscr_relation",center,courier);evidence.add(relations);if(relations.stream().anyMatch(r->id(r,"cscr_relation")==0))blockers.add("Für einen beteiligten Kunden ist dieser Transporteur gesperrt (Kostenstelle "+center+").");} + var countries=new TreeSet();s.stops().forEach(t->countries.add(country(text(t,"ad_country")))); + String defaultCountry=global("GLOBAL_NATIONALITY_DEFAULT",evidence);if(defaultCountry.isEmpty())defaultCountry="DE"; + String nationality=country(text(v,"usr_country")),whitelist=global("GLOBAL_NATIONALITY_WHITELIST",evidence); + if(!nationality.isEmpty()&&!nationality.equals(defaultCountry))for(String entry:whitelist.split("\\|")){var parts=entry.split("=",2);if(parts.length==2&&countries.contains(parts[0].strip())&&!parts[0].strip().equals(defaultCountry)&&!parts[0].strip().equals(nationality)&&!tokens(parts[1]).contains(nationality))blockers.add("Die hinterlegte Länderfreigabe ist für diese Tour nicht erfüllt.");} + String eu=global("EU_COUNTRYCODES",evidence); + if(id(v,"crvh_totalweight")>=2800&&!vehicleFilters.contains("EG")&&countries.stream().anyMatch(c->!c.equals("DE")&&!c.isEmpty()&&eu.contains(c)))warnings.add("Für diese Auslandstour fehlt die EG-Lizenz des Fahrzeugs."); + String start=s.stops().isEmpty()?"":text(s.stops().getFirst(),"ad_zipcode");String rankingAfter="";boolean deferred=false; + if(fromList){ + // jb_list.php uses the global PLZ lead time, conditional on an HQ mapping. + var lead=db.queryForList("SELECT p.srvp_id,p.srvp_traveltime FROM serviceplz p JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id WHERE p.srvp_plz=? AND t.hq_id=? ORDER BY p.srvp_id",start,id(j,"hq_id_dispo"));evidence.add(lead); + if(lead.size()>1)throw bad("Die Anfahrtszeit für den Rankingverlust ist mehrdeutig."); + var orderTime=dateTime(j,"jb_ordertime"); + if(orderTime!=null&&lead.size()==1&&lead.getFirst().get("srvp_traveltime")!=null){ + var threshold=orderTime.minusMinutes(Math.max(30,id(lead.getFirst(),"srvp_traveltime")));rankingAfter=threshold.toString(); + deferred=!db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().isAfter(threshold); + } + } + boolean lose=!deferred&&loseRanking(u,text(v,"cr_locationzipcode"),start,evidence);evidence.add(rankingAfter);evidence.add(deferred); + if(deferred)warnings.add("Künftiger Auftrag: Rankingverlust wird erst nach "+rankingAfter.replace('T',' ')+" geprüft."); + evidence.add(warnings);evidence.add(blockers);evidence.add(lose); + var p=new Preview(job,courier,id(v,"crvh_id"),text(v,"crvh_sid"),text(v,"crvh_vh_sign"),text(j,"cr_sid"),revision(s),revision(evidence),lose,List.copyOf(warnings),List.copyOf(blockers)); + return new Prepared(p,evidence,v,rankingAfter); + } + boolean loseRanking(LegacyUser u,String from,String to,List evidence){return new FleetRanking(db,catalog).area(u,from,to,evidence).contains();} + private String setting(LegacyUser u,String key,List evidence){String value=catalog.parameter(u.headquartersId(),key);if(value.isEmpty())value=catalog.parameter(0,key);evidence.add(List.of(key,value));return value;} + private String global(String key,List evidence){String value=catalog.parameter(0,key);evidence.add(List.of(key,value));return value;} + static Set tokens(String value){var result=new TreeSet();for(String item:value.split(","))if(!item.isBlank())result.add(item.strip());return result;} + private static String country(String c){return c.equals("D")?"DE":c;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/FleetLocation.java b/backend/src/main/java/de/stadtbote/votian/FleetLocation.java new file mode 100644 index 0000000..2ebf1ec --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FleetLocation.java @@ -0,0 +1,146 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.ApiController.user; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Checks stored positions. Does not request device locations or write during previews. */ +@RestController +@RequestMapping("/api/administration/fleet-map/couriers/{courier}/location-check") +public class FleetLocation { + private final DatabaseService db;private final BusinessSupport support;private final OrderEntryCatalog catalog; + public FleetLocation(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog){this.db=db;this.support=support;this.catalog=catalog;} + public record Command(String requestId,String fingerprint){} + record Preview(long courier,String sid,String checkedAt,String locatedAt,String source,Long ageSeconds,long maximumAgeSeconds, + FleetMap.Position point,String postalCode,String method,String reportedPostalCode,String reportedAt, + boolean change,boolean loseRanking,boolean canSave,String outcome,List allowedPostalCodes, + List warnings,List blockers,String fingerprint){} + private record Prepared(Preview preview,Object evidence){} + record Plausibility(boolean plausible,String reason,Long speedKmh){} + record Postal(String code,String method){} + @GetMapping Preview get(Authentication auth,@PathVariable long courier){return preview(user(auth),courier);} + @PostMapping Map post(Authentication auth,@PathVariable long courier,@RequestBody Command c){return apply(user(auth),courier,c);} + static void access(LegacyUser u){u.require(19);u.require(4);} + Preview preview(LegacyUser u,long courier){access(u);return db.readTransaction(()->prepare(u,courier,false).preview());} + Map apply(LegacyUser u,long courier,Command c){ + access(u);u.require(7);if(c==null||c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte zuerst die Ortungsprüfung laden.");BusinessSupport.token(c.requestId()); + support.transactional("headquarters","courier","genericdatacontainer"); + db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,c.requestId(),List.of("fleet-location",courier,c))!=null)return null; + var prepared=prepare(u,courier,true);unchanged(prepared.evidence(),c.fingerprint());var p=prepared.preview(); + if(!p.blockers().isEmpty())throw bad(String.join(" · ",p.blockers())); + if(p.change())db.update("UPDATE courier SET cr_locationzipcode=?,cr_availabletime=? WHERE cr_id=? AND hq_id=?",p.postalCode(),LocalDateTime.parse(p.checkedAt()),courier,u.headquartersId()); + support.audit(u,0,0,courier,27,"Ortungsprüfung · "+p.sid()+" · ZIP_REAL="+p.postalCode()+"|ZIP_FREE="+p.reportedPostalCode()+"|LRANK="+(p.loseRanking()?"YES":"NO")+"|METHOD="+p.method()+"|LOC_AT="+p.locatedAt()+"|LONG="+p.point().longitude()+"|LAT="+p.point().latitude(),c.requestId()); + support.receipt(u,c.requestId(),List.of("fleet-location",courier,c),courier);return null; + });return fields("courier",courier,"completed",true); + } + private Prepared prepare(LegacyUser u,long courier,boolean lock){ + String sql="SELECT cr_id,hq_id,usr_id,cr_sid,cr_available,cr_locationzipcode,cr_availabletime,cr_gps_time,cr_gps_type,cr_gps_acc,cr_gps_lat,cr_gps_long FROM courier WHERE cr_id=? AND hq_id=?"; + var row=one(lock?db.lockRows(sql,courier,u.headquartersId()):db.queryForList(sql,courier,u.headquartersId())); + var now=dateTime(one(db.queryForList("SELECT NOW() AS at")),"at");var evidence=new ArrayList();evidence.add(row); + var warnings=new ArrayList();var blockers=new ArrayList(); + if(text(row,"cr_sid").isBlank()||id(row,"cr_available")!=1)blockers.add("Der Transporteur ist nicht online angemeldet."); + long maxAge=integer(parameter(0,"LOCATING_SECONDS_NEEDED_FOR_LBS_LOCATING",evidence),900,1,86400,"Maximales Ortungsalter"); + long type=id(row,"cr_gps_type");var at=dateTime(row,"cr_gps_time");Long age=at==null?null:Duration.between(at,now).getSeconds(); + var point=FleetMap.position(row,"cr_gps_lat","cr_gps_long"); + if(point==null)blockers.add("Die gespeicherten Koordinaten sind ungültig oder fehlen."); + if(type==9)blockers.add("Die Ortung ist auf dem Endgerät ausgeschaltet."); + else if(type!=0&&type!=2&&type!=3)blockers.add("Für diese Ortungsquelle liegt keine aktuelle GPS-/Netzwerkposition vor."); + if(age==null||age<0)blockers.add("Der Ortungszeitpunkt fehlt oder liegt in der Zukunft."); + else if(age>maxAge)blockers.add("Die Positionsangabe ist veraltet. Eine neue Freimeldung wird daraus nicht übernommen."); + Postal postal=new Postal("","");var allowed=List.of();boolean change=false,lose=false; + String old=text(row,"cr_locationzipcode"),outcome="Die Freimeldung bleibt unverändert."; + if(point!=null&&type!=9){ + String mode=parameter(u.headquartersId(),"LOCATING_MODE",evidence);if(mode.isEmpty())mode="0"; + if(!Set.of("0","1").contains(mode))throw bad("Ungültige Einstellung LOCATING_MODE."); + postal=postal(point,mode,evidence); + if(postal.code().isEmpty())blockers.add("Für die Position konnte keine eindeutige PLZ ermittelt werden."); + else { + boolean numeric=old.matches("[0-9]+"); + var area=new FleetRanking(db,catalog).area(u,old,postal.code(),evidence);allowed=area.postalCodes(); + change=!numeric||!area.contains();lose=numeric&&change; + if(change){ + var plausible=plausibility(u,row,point,evidence); + if(!plausible.plausible())blockers.add(plausible.reason()); + else if(plausible.speedKmh()!=null)warnings.add("Netzwerkposition plausibel: rechnerisch "+plausible.speedKmh()+" km/h seit der vorherigen GPS-Ortung."); + outcome=lose?"Freimelde-PLZ auf "+postal.code()+" ändern und Rankingzeit auf den Bestätigungszeitpunkt setzen.":"Freimeldung mit PLZ "+postal.code()+" und dem Bestätigungszeitpunkt ergänzen."; + } else outcome="Position im Freimelde- oder Nachbarbereich. PLZ und Rankingzeit bleiben erhalten."; + if(postal.method().equals("NEAREST"))warnings.add("PLZ über den nächstgelegenen gespeicherten PLZ-Mittelpunkt ermittelt."); + } + } + if(type==0)warnings.add("Die Quelle der gespeicherten Position ist nicht angegeben."); + // Time is intentionally excluded; aging into the blocked state changes the fingerprint. + evidence.add(postal);evidence.add(allowed);evidence.add(change);evidence.add(lose);evidence.add(blockers);evidence.add(warnings); + var p=new Preview(courier,text(row,"cr_sid"),now.toString(),text(row,"cr_gps_time"),FleetMap.source(type),age,maxAge,point,postal.code(),postal.method(),old,text(row,"cr_availabletime"),change,lose,u.hasRight(7)&&blockers.isEmpty(),outcome,allowed,List.copyOf(warnings),List.copyOf(blockers),revision(evidence)); + return new Prepared(p,evidence); + } + Postal postal(FleetMap.Position point,String mode,List evidence){ + if(mode.equals("0")){ + var rows=db.queryForList(""" + SELECT g.plzg_code,p.plzp_polygons, + ACOS(LEAST(1,GREATEST(-1,SIN(RADIANS(?))*SIN(RADIANS(g.plzg_breite)) + +COS(RADIANS(?))*COS(RADIANS(g.plzg_breite))*COS(RADIANS(g.plzg_laenge)-RADIANS(?))))) * 6371 AS km + FROM phoenix_special.plz_geodb g LEFT JOIN phoenix_special.plz_polygon p ON p.plzp_code=g.plzg_code + HAVING km<50 ORDER BY km,g.plzg_code LIMIT 2001 + """,point.latitude(),point.latitude(),point.longitude()); + if(rows.size()>2000)throw bad("Zu viele PLZ-Gebiete im Suchradius. Bitte Geodaten prüfen."); + var matches=new TreeSet(); + for(var r:rows){String polygon=text(r,"plzp_polygons");if(polygon.isBlank())continue; + if(contains(polygon,point))matches.add(text(r,"plzg_code"));} + evidence.add(revision(rows)); + if(matches.size()>1)throw bad("Die gespeicherten PLZ-Polygone überlagern sich. Bitte die Zuordnung prüfen."); + if(matches.size()==1)return new Postal(matches.first(),"POLYGON"); + } + var nearest=db.queryForList(""" + SELECT srvp_id,srvp_plz,srvp_latitude,srvp_longitude, + POW(srvp_latitude-?,2)+POW(srvp_longitude-?,2) AS distance + FROM serviceplz WHERE srvp_latitude BETWEEN -85.05112878 AND 85.05112878 + AND srvp_longitude BETWEEN -180 AND 180 AND NOT(srvp_latitude=0 AND srvp_longitude=0) + AND TRIM(COALESCE(srvp_plz,''))<>'' ORDER BY distance,srvp_id LIMIT 2 + """,point.latitude(),point.longitude());evidence.add(nearest); + if(nearest.isEmpty())return new Postal("",""); + if(nearest.size()>1&&Double.compare(((Number)nearest.get(0).get("distance")).doubleValue(),((Number)nearest.get(1).get("distance")).doubleValue())==0&&!text(nearest.get(0),"srvp_plz").equals(text(nearest.get(1),"srvp_plz")))throw bad("Die nächstgelegene PLZ ist nicht eindeutig."); + return new Postal(text(nearest.getFirst(),"srvp_plz"),"NEAREST"); + } + static boolean contains(String polygons,FleetMap.Position point){ + if(polygons.length()>2_000_000)throw bad("Das PLZ-Polygon überschreitet die zulässige Größe."); + for(String ring:polygons.split("\\|",-1)){ + var vertices=new HashSet(); + for(String item:ring.split(",",-1)){ + String[] pair=item.strip().split("\\s+"); + if(pair.length!=2)throw bad("Ungültiges PLZ-Polygon im Bestand."); + try{double x=Double.parseDouble(pair[0]),y=Double.parseDouble(pair[1]);if(!Double.isFinite(x)||!Double.isFinite(y)||Math.abs(x)>180||Math.abs(y)>90)throw new NumberFormatException();vertices.add(x+":"+y);} + catch(NumberFormatException e){throw bad("Ungültiges PLZ-Polygon im Bestand.");} + } + if(vertices.size()<3)throw bad("Ungültiges PLZ-Polygon im Bestand."); + } + return OrderRoutingService.contains(polygons,new OrderRoutingService.Point(point.latitude(),point.longitude())); + } + private Plausibility plausibility(LegacyUser u,Map row,FleetMap.Position point,List evidence){ + if(id(row,"cr_gps_type")!=3)return new Plausibility(true,"",null); + var previous=db.queryForList("SELECT loc_long,loc_lat,loc_acc,loc_time,loc_created FROM "+support.historySchema+".locating WHERE usr_id=? AND loc_type=2 AND loc_created evidence){String value=catalog.parameter(hq,key);evidence.add(List.of(hq,key,value));return value;} + static long integer(String value,long fallback,long min,long max,String label){double n=number(value,fallback,min,max,label);if(n!=Math.floor(n))throw bad(label+" muss ganzzahlig sein.");return (long)n;} + static double number(String value,double fallback,double min,double max,String label){try{double n=value.isBlank()?fallback:Double.parseDouble(value);if(!Double.isFinite(n)||nmax)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad(label+" ist ungültig konfiguriert.");}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/FleetMap.java b/backend/src/main/java/de/stadtbote/votian/FleetMap.java new file mode 100644 index 0000000..d0e954c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FleetMap.java @@ -0,0 +1,97 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.ApiController.user; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Read-only headquarters map. No locating commands, ranking changes or writes on refresh. */ +@RestController +@RequestMapping("/api/administration/fleet-map") +public class FleetMap { + private final DatabaseService db; + private final OrderRoutingService routing; + private final String tiles, attribution; + public FleetMap(DatabaseService db,OrderRoutingService routing, + @Value("${app.fleet-map.tiles:https://tile.openstreetmap.org/{z}/{x}/{y}.png}") String tiles, + @Value("${app.fleet-map.attribution:© OpenStreetMap contributors}") String attribution) { + this.db=db;this.routing=routing;this.tiles=tiles;this.attribution=attribution; + } + record Position(double latitude,double longitude) {} + record Job(long id,String at,String route,int status) {} + record Courier(long id,String sid,String name,int vehicleType,String vehicleLabel,boolean online,boolean occupied, + String postalCode,String availableAt,String locatedAt,String source,Long ageMinutes,Position point,String warning, + String mobile,String phone,String phone2,List jobs) {} + record Place(String key,long id,String kind,String label,String address,Position point,boolean completed,String completedAt) {} + record Snapshot(String at,String day,String headquarters,Position center,String tiles,String attribution, + boolean orders,boolean couriers,boolean assignment,List items) {} + @GetMapping Snapshot get(Authentication auth){return snapshot(user(auth));} + Snapshot snapshot(LegacyUser u) { + u.require(19); + var now=dateTime(one(db.queryForList("SELECT NOW() AS at")),"at"); + var hq=one(db.queryForList("SELECT hq_name,hq_gps_lat,hq_gps_long FROM headquarters WHERE hq_id=?",u.headquartersId())); + var rows=db.queryForList(""" + SELECT cr.cr_id,cr.cr_sid,cr.vht_id,cr.cr_available,cr.cr_occupied,cr.cr_locationzipcode,cr.cr_availabletime, + cr.cr_gps_time,cr.cr_gps_type,cr.cr_gps_lat,cr.cr_gps_long,cr.cr_mobile_pda,u.usr_name,u.usr_firstname,u.usr_phone,u.usr_phone2 + FROM courier cr JOIN user u ON u.usr_id=cr.usr_id + WHERE cr.hq_id=? AND TRIM(COALESCE(cr.cr_sid,''))<>'' AND cr.cr_gps_time>? ORDER BY cr.cr_sid,cr.cr_id LIMIT 2001 + """,u.headquartersId(),now.toLocalDate().atStartOfDay()); + limit(rows,2000,"Transporteurpositionen"); + var types=new HashMap(); + for(var t:db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='vehicletype' AND COALESCE(mt_objtype,'')='' AND COALESCE(mt_objid,0)=0 ORDER BY mt_id"))types.putIfAbsent(id(t,"mt_sort"),text(t,"mt_value")); + var jobs=new HashMap>(); + if(u.hasRight(4)&&!rows.isEmpty()) { + var ids=rows.stream().map(r->id(r,"cr_id")).toList();var params=new ArrayList();params.add(u.headquartersId());params.addAll(ids); + var active=db.queryForList("SELECT jb_id,jb_ordertime,jb_tourdata,jb_status,CASE WHEN jb_status=0 THEN cr_id_order ELSE cr_id END AS courier FROM job WHERE hq_id=? AND jb_status IN (0,1) AND COALESCE(jb_storno,0)=0 AND (CASE WHEN jb_status=0 THEN cr_id_order ELSE cr_id END) IN ("+String.join(",",Collections.nCopies(ids.size(),"?"))+") ORDER BY jb_ordertime,jb_id LIMIT 5001",params.toArray()); + limit(active,5000,"laufende Aufträge"); + for(var j:active)jobs.computeIfAbsent(id(j,"courier"),k->new ArrayList<>()).add(new Job(id(j,"jb_id"),text(j,"jb_ordertime"),text(j,"jb_tourdata"),(int)id(j,"jb_status"))); + } + var items=new ArrayList(); + for(var r:rows) { + var point=position(r,"cr_gps_lat","cr_gps_long");var at=dateTime(r,"cr_gps_time"); + long type=id(r,"cr_gps_type");String warning="";Long age=at==null?null:Duration.between(at,now).toMinutes(); + if(type==9){point=null;warning="Ortung ausgeschaltet";} + else if(at==null||at.isAfter(now.plusMinutes(5))){point=null;warning="Ungültiger Ortungszeitpunkt";} + else if(point==null)warning="Ungültige oder fehlende Koordinaten"; + if(age!=null)age=Math.max(0,age); + long id=id(r,"cr_id"),vht=id(r,"vht_id"); + items.add(new Courier(id,text(r,"cr_sid"),(text(r,"usr_firstname")+" "+text(r,"usr_name")).strip(),(int)vht,types.getOrDefault(vht,"Typ "+vht),id(r,"cr_available")==1,id(r,"cr_occupied")>0,text(r,"cr_locationzipcode"),text(r,"cr_availabletime"),text(r,"cr_gps_time"),source(type),age,point,warning,text(r,"cr_mobile_pda"),text(r,"usr_phone"),text(r,"usr_phone2"),jobs.getOrDefault(id,List.of()))); + } + return new Snapshot(now.toString(),now.toLocalDate().toString(),text(hq,"hq_name"),position(hq,"hq_gps_lat","hq_gps_long"),tileUrl(tiles),attribution,u.hasRight(4),u.hasRight(1),u.hasRight(4)&&u.hasRight(7),items); + } + @GetMapping("/places") List places(Authentication auth){return places(user(auth));} + List places(LegacyUser u){ + u.require(19);var result=new ArrayList(); + var hq=one(db.queryForList("SELECT hq_name,hq_gps_lat,hq_gps_long FROM headquarters WHERE hq_id=?",u.headquartersId())); + result.add(new Place("HQ",u.headquartersId(),"HQ",text(hq,"hq_name"),"",position(hq,"hq_gps_lat","hq_gps_long"),false,"")); + var rows=db.queryForList("SELECT s.stk_id,s.stk_name,s.stk_hsno AS house,a.* FROM stock s JOIN address a ON a.ad_id=s.ad_id WHERE s.hq_id=? AND s.ad_id>0 ORDER BY s.stk_name,s.stk_id LIMIT 201",u.headquartersId()); + limit(rows,200,"Lageradressen");for(var r:rows)result.add(place(r,"STOCK",id(r,"stk_id"),text(r,"stk_name"),false,""));return result; + } + @GetMapping("/orders/{job}") List stations(Authentication auth,@PathVariable long job){return stations(user(auth),job);} + List stations(LegacyUser u,long job){ + order(u,job);var rows=db.queryForList("SELECT t.tr_id,t.tr_sort,t.tr_comp,t.tr_comp2,t.tr_hsno AS house,t.tr_status,t.tr_finishtime,a.* FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 101",job); + limit(rows,100,"Stationen");return rows.stream().map(r->place(r,"STOP",id(r,"tr_id"),id(r,"tr_sort")+" · "+text(r,"tr_comp")+" "+text(r,"tr_comp2"),id(r,"tr_status")==1,text(r,"tr_finishtime"))).toList(); + } + @GetMapping("/stocks/{stock}/position") Position stockPosition(Authentication auth,@PathVariable long stock){return stockPosition(user(auth),stock);} + Position stockPosition(LegacyUser u,long stock){u.require(19);return locate(one(db.queryForList("SELECT s.stk_hsno AS house,a.* FROM stock s JOIN address a ON a.ad_id=s.ad_id WHERE s.stk_id=? AND s.hq_id=?",stock,u.headquartersId())));} + @GetMapping("/orders/{job}/stations/{station}/position") Position stationPosition(Authentication auth,@PathVariable long job,@PathVariable long station){return stationPosition(user(auth),job,station);} + Position stationPosition(LegacyUser u,long job,long station){order(u,job);return locate(one(db.queryForList("SELECT t.tr_hsno AS house,a.* FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? AND t.tr_id=?",job,station)));} + private void order(LegacyUser u,long job){u.require(19);u.require(4);one(db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",job,u.headquartersId()));} + private Position locate(Map r){ + String country=text(r,"ad_country");if(country.equals("D"))country="DE"; + if(text(r,"ad_street").isBlank()||text(r,"ad_city").isBlank()||text(r,"ad_zipcode").isBlank()||!country.matches("[A-Z]{2}"))throw bad("Die gespeicherte Adresse ist unvollständig."); + var p=routing.geocode(new OrderEntryModels.Station(null,Models.StopType.BOTH,"Kartenadresse",text(r,"ad_street"),text(r,"house"),text(r,"ad_zipcode"),text(r,"ad_city"),country,"","","","",0,null,List.of())); + var point=position(fields("lat",p.latitude(),"lng",p.longitude()),"lat","lng");if(point==null)throw bad("Für diese Adresse liegt keine darstellbare Position vor.");return point; + } + static Position position(Map r,String lat,String lng){try{double y=Double.parseDouble(text(r,lat)),x=Double.parseDouble(text(r,lng));return !Double.isFinite(y)||!Double.isFinite(x)||Math.abs(y)>85.05112878||Math.abs(x)>180||(x==0&&y==0)?null:new Position(y,x);}catch(NumberFormatException e){return null;}} + private static Place place(Map r,String kind,long id,String label,boolean done,String at){return new Place(kind+id,id,kind,label.strip(),text(r,"ad_street")+" "+text(r,"house")+", "+text(r,"ad_zipcode")+" "+text(r,"ad_city")+" "+text(r,"ad_country"),null,done,at);} + static String source(long type){return switch((int)type){case 1->"LBS";case 2->"GPS";case 3->"Network";case 9->"Ortung aus";default->"Unbestimmt";};} + static String tileUrl(String value){try{var uri=java.net.URI.create(value.replace("{z}","1").replace("{x}","1").replace("{y}","1"));if(!"https".equals(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||!value.contains("{z}")||!value.contains("{x}")||!value.contains("{y}"))throw new IllegalArgumentException();return value;}catch(IllegalArgumentException e){throw bad("Der Kartenhintergrund ist nicht gültig konfiguriert (FLEET_MAP_TILE_URL).");}} + private static void limit(List rows,int max,String what){if(rows.size()>max)throw bad("Mehr als "+max+" "+what+" vorhanden. Die Karte wird nicht unvollständig angezeigt.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java b/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java new file mode 100644 index 0000000..d1faf21 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FleetMapContext.java @@ -0,0 +1,47 @@ +package de.stadtbote.votian; + +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 order overlay. Refreshing never changes a courier's ranking or assignment. */ +@RestController +@RequestMapping("/api/administration/fleet-map/orders/{job}/context") +public class FleetMapContext { + private final DatabaseService db; + private final ClassicCandidates candidates; + private final BusinessSupport support; + public FleetMapContext(DatabaseService db,ClassicCandidates candidates,BusinessSupport support){this.db=db;this.candidates=candidates;this.support=support;} + @GetMapping public Object get(Authentication auth,@PathVariable long job){return context(ApiController.user(auth),job);} + Map context(LegacyUser u,long job){ + u.require(19);u.require(4); + return db.readTransaction(()->{ + var j=one(db.queryForList("SELECT jb_id,jb_status,jb_storno,jb_offer,jb_longhaul,cr_id,cr_id_order,cr_sid FROM job WHERE jb_id=? AND hq_id=?",job,u.headquartersId())); + String reason=!u.hasRight(7)?"Keine Berechtigung zur Disposition.":id(j,"jb_storno")!=0?"Der Auftrag ist storniert.":id(j,"jb_offer")!=0?"Der Auftrag ist ein Angebot.":id(j,"jb_longhaul")!=0?"Für diesen Auftrag ist die BWV-Vermittlung erforderlich.":!Set.of(0L,1L,8L,9L).contains(id(j,"jb_status"))?"Der Auftrag ist abgeschlossen.":""; + long assigned=Set.of(8L,9L).contains(id(j,"jb_status"))?0:id(j,id(j,"jb_status")==0?"cr_id_order":"cr_id"); + // Prefer stable courier identity. SID fallback is only for old logs without an ID, + // and only when that SID identifies one courier inside this headquarters. + var rejections=db.queryForList(""" + SELECT cr.cr_id AS courierId,COUNT(*) AS attempts,MAX(l.log_createtime) AS lastAt + FROM %s.log l JOIN courier cr ON cr.hq_id=? AND + (l.cr_id=cr.cr_id OR (COALESCE(l.cr_id,0)=0 AND l.cr_sid<>'' AND l.cr_sid=cr.cr_sid + AND NOT EXISTS(SELECT 1 FROM courier other WHERE other.hq_id=cr.hq_id AND other.cr_sid=cr.cr_sid AND other.cr_id<>cr.cr_id))) + WHERE l.jb_id=? AND l.hq_id=? AND l.logo_id=11 + GROUP BY cr.cr_id ORDER BY cr.cr_id + """.formatted(support.historySchema),u.headquartersId(),job,u.headquartersId()).stream().map(r->fields("courierId",id(r,"courierId"),"attempts",id(r,"attempts"),"lastAt",text(r,"lastAt"))).toList(); + 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())){ + rank++;if(!own.contains(id(value,"courierId")))continue; + var item=new LinkedHashMap<>(value);item.put("rank",rank);items.add(item); + } + return fields("job",job,"assignedCourier",assigned,"assignedSid",assigned>0?text(j,"cr_sid"):"","canAssign",reason.isEmpty(),"reason",reason, + "startPostalCode",ranking.getOrDefault("startPostalCode",""),"area",ranking.getOrDefault("area",""),"warnings",ranking.getOrDefault("warnings",List.of()),"items",items,"rejections",rejections); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/FleetRanking.java b/backend/src/main/java/de/stadtbote/votian/FleetRanking.java new file mode 100644 index 0000000..54d059a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FleetRanking.java @@ -0,0 +1,22 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +/** Shared directed neighbourhood rules for assignment and free-position verification. */ +final class FleetRanking { + private final DatabaseService db;private final OrderEntryCatalog catalog; + FleetRanking(DatabaseService db,OrderEntryCatalog catalog){this.db=db;this.catalog=catalog;} + record Area(boolean contains,List postalCodes){} + Area area(LegacyUser u,String from,String to,List evidence){ + if(from.isBlank())return new Area(false,List.of());if(from.equals(to))return new Area(true,List.of(from)); + String mode=catalog.parameter(u.headquartersId(),"MODE_INTERMEDIATION");if(mode.isBlank())mode=text(one(db.queryForList("SELECT hq_invmode FROM headquarters WHERE hq_id=?",u.headquartersId())),"hq_invmode");evidence.add(mode); + var zip=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? AND srvp_country IN ('D','DE') ORDER BY srvp_id",from);evidence.add(zip); + if(zip.size()>1)throw bad("Die Freimelde-PLZ ist im Bestand mehrdeutig.");if(zip.isEmpty())return new Area(false,List.of(from));long source=id(zip.getFirst(),"srvp_id"); + List> targets; + if(mode.equals("1"))targets=db.queryForList("SELECT p.srvp_plz FROM serviceplzneighbour n JOIN serviceplz p ON p.srvp_id=n.srvp2_id WHERE n.hq_id=? AND n.srvp_id=? ORDER BY p.srvp_plz",u.headquartersId(),source); + else if(mode.equals("2"))targets=db.queryForList("SELECT DISTINCT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE a.hq_id=? AND (a.srvpa_id IN (SELECT source.srvpa_id FROM serviceplzareamapping source JOIN serviceplzarea sa ON sa.srvpa_id=source.srvpa_id WHERE source.srvp_id=? AND sa.hq_id=?) OR a.srvpa_id IN (SELECT an.srvpa2_id FROM serviceplzareaneighbour an JOIN serviceplzareamapping neighbour_source ON neighbour_source.srvpa_id=an.srvpa_id JOIN serviceplzarea neighbour_area ON neighbour_area.srvpa_id=neighbour_source.srvpa_id WHERE neighbour_source.srvp_id=? AND neighbour_area.hq_id=?)) ORDER BY p.srvp_plz",u.headquartersId(),source,u.headquartersId(),source,u.headquartersId()); + else targets=db.queryForList("SELECT DISTINCT p.srvp_plz FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE a.hq_id=? AND (a.srvpa_id IN (SELECT srvpa_id FROM serviceplzareamapping WHERE srvp_id=?) OR a.srvpa_id IN (SELECT srvpa_id FROM serviceplzneighbourarea WHERE hq_id=? AND srvp_id=?)) ORDER BY p.srvp_plz",u.headquartersId(),source,u.headquartersId(),source); + evidence.add(targets);var codes=new TreeSet();codes.add(from);targets.forEach(r->codes.add(text(r,"srvp_plz")));return new Area(codes.contains(to),List.copyOf(codes)); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/FormAdministration.java b/backend/src/main/java/de/stadtbote/votian/FormAdministration.java index 9949fd7..aff5cb9 100644 --- a/backend/src/main/java/de/stadtbote/votian/FormAdministration.java +++ b/backend/src/main/java/de/stadtbote/votian/FormAdministration.java @@ -38,7 +38,7 @@ class FormAdministration { if((fid==0||!name.equals(text(before,"mtfk_name")))&&!db.queryForList("SELECT mtfk_id FROM metafieldkey WHERE mtfk_id<>? AND mtfk_name=?",fid,name).isEmpty())throw bad("Dieser Feldname ist bereits vergeben."); boolean known=all.stream().anyMatch(r->text(r,"mtfk_type").equals(type)); if(type.startsWith("ref:")&&!(fid>0&&type.equals(text(before,"mtfk_type"))&&!type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))){if(!type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))throw bad("Kombinierte Felder als ref:ID:ID angeben.");var references=Arrays.stream(type.substring(4).split(":")).map(Long::parseLong).toList();var available=all.stream().map(r->id(r,"mtfk_id")).toList();if(!available.containsAll(references)||references.contains(fid))throw bad("Ungültige Feldreferenz.");var graph=new HashMap>();for(var r:all){String t=text(r,"mtfk_type");if(t.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))graph.put(id(r,"mtfk_id"),Arrays.stream(t.substring(4).split(":")).map(Long::parseLong).toList());}if(fid>0){graph.put(fid,references);checkCycle(fid,graph,new HashSet<>(),new HashSet<>());}known=true;} - if(!known&&!LegacyFormTypes.MAPPED.contains(type)&&!Set.of("varchar","text","checkbox","date","date_begin","date_end","datetime","time","birthdate","number","author","co2cs","image").contains(type))throw bad("Bitte einen vorhandenen Feldtyp auswählen."); + if(!known&&!LegacyFormTypes.MAPPED.contains(type)&&!FormFieldSupport.editable(type)&&!Set.of("varchar","text","checkbox","date","date_begin","date_end","datetime","time","birthdate","number","author","co2cs","image").contains(type))throw bad("Bitte einen vorhandenen Feldtyp auswählen."); long saved=fid;if(fid==0)saved=db.insert("metafieldkey",fields("mtfk_name",name,"mtfk_type",type));else db.update("UPDATE metafieldkey SET mtfk_name=?,mtfk_type=? WHERE mtfk_id=?",name,type,fid);return fields("id",saved); });} private static void checkCycle(long id,Map> graph,Set path,Set done){if(done.contains(id))return;if(!path.add(id))throw bad("Zyklische Feldreferenzen sind nicht erlaubt.");for(long child:graph.getOrDefault(id,List.of()))checkCycle(child,graph,path,done);path.remove(id);done.add(id);} diff --git a/backend/src/main/java/de/stadtbote/votian/FormAutomaticValues.java b/backend/src/main/java/de/stadtbote/votian/FormAutomaticValues.java new file mode 100644 index 0000000..ac45fc5 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FormAutomaticValues.java @@ -0,0 +1,35 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.MasterForms.*; + +/** Shared read-only defaults for object forms; saved overrides stay in the form's EAV values. */ +final class FormAutomaticValues { + final DatabaseService db; + final FormFieldSupport special; + FormAutomaticValues(DatabaseService db,FormFieldSupport special){this.db=db;this.special=special;} + Map values(LegacyUser u,String kind,Map owner,List> definitions,List> links){var result=new LinkedHashMap(); + var source=new LinkedHashMap<>(owner); + var keys=definitions.stream().map(d->dbKey(kind,text(d,"type"))).filter(Objects::nonNull).toList(); + if(keys.stream().anyMatch(k->k.startsWith("cmp_")||k.startsWith("ad_"))){ + var company=id(owner,"cmp_id")>0?optional(db.queryForList("SELECT cmp_comp,cmp_comp2,ad_id,cmp_hsno,cmp_tax_idno,cmp_stax_idno,cmp_bank,cmp_bankno,cmp_bankacc,cmp_iban,cmp_swift,cmp_postage FROM company WHERE cmp_id=?",id(owner,"cmp_id"))):Map.of(); + source.putAll(company); + if(keys.stream().anyMatch(k->k.startsWith("ad_"))&&id(company,"ad_id")>0) + source.putAll(optional(db.queryForList("SELECT ad_street,ad_zipcode,ad_city FROM address WHERE ad_id=?",id(company,"ad_id")))); + } + if(keys.stream().anyMatch(k->k.startsWith("usr_"))){ + long uid=id(owner,"usr_id"); + if(kind.equals("customers"))uid=id(owner,"cs_admin")>0?id(optional(db.queryForList("SELECT usr_id FROM employee WHERE emp_id=?",id(owner,"cs_admin"))),"usr_id"):0; + if(uid>0)source.putAll(optional(db.queryForList("SELECT usr_name,usr_firstname,usr_phone,usr_phone2,usr_fax,usr_email,usr_birthdate FROM user WHERE usr_id=?",uid))); + } + var system=new HashMap();system.put("empty_field","");system.put("current_date",LocalDate.now(ZoneId.of("Europe/Berlin")).format(EvaluationService.DATE));system.put("current_user",definitions.stream().anyMatch(d->text(d,"type").equals("sys:current_user"))?special.currentUser(u):u.username());system.put("current_headquarters_id",Long.toString(u.headquartersId())); + if(definitions.stream().anyMatch(d->text(d,"type").startsWith("sys:current_headquarters_"))){var h=one(db.queryForList("SELECT h.hq_name,h.hq_mnemonic,c.cmp_comp,c.cmp_comp2,c.cmp_logo,c.cmp_logo_width,c.cmp_logo_height,c.cmp_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM headquarters h LEFT JOIN company c ON c.cmp_id=h.cmp_id LEFT JOIN address a ON a.ad_id=c.ad_id WHERE h.hq_id=?",u.headquartersId()));for(var entry:Map.of("name","hq_name","mnemonic","hq_mnemonic","company_name","cmp_comp","company_name2","cmp_comp2","street","ad_street","housenumber","cmp_hsno","zipcode","ad_zipcode","city","ad_city").entrySet())system.put("current_headquarters_"+entry.getKey(),text(h,entry.getValue()));system.put("current_headquarters_company_logo",text(h,"cmp_logo").isBlank()?"":text(h,"cmp_logo")+","+(id(h,"cmp_logo_width")>0?id(h,"cmp_logo_width"):"")+","+(id(h,"cmp_logo_height")>0?id(h,"cmp_logo_height"):""));} + for(var d:definitions){String t=text(d,"type"),key=dbKey(kind,t);if(key!=null){String v=text(source,key);if(key.equals("usr_birthdate"))v=v.length()>=10&&!v.startsWith("0000")?LocalDate.parse(v.substring(0,10)).format(EvaluationService.DATE):"";result.put(id(d,"id"),v);}else if(t.startsWith("sys:")&&SYSTEM.contains(t.substring(4)))result.put(id(d,"id"),system.getOrDefault(t.substring(4),""));} + for(var l:links)result.put(id(l,"fieldId"),text(l,"label")+(Boolean.TRUE.equals(l.get("available"))?"":" (nicht eingerichtet)"));return result; + } + private static Map optional(List> rows){return rows.isEmpty()?Map.of():one(rows);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/FormContactValues.java b/backend/src/main/java/de/stadtbote/votian/FormContactValues.java new file mode 100644 index 0000000..9f27082 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FormContactValues.java @@ -0,0 +1,34 @@ +package de.stadtbote.votian; + +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.text; + +/** Mail-field validation from the current PHP checkFormTags.js, without executing scripts. */ +final class FormContactValues { + private static final Set MAIL=Set.of("mail","email","mail_business","mail_private","email_business","email_private"); + private static final Set PHONE=Set.of("phone","mobile","fax","phone_business","phone_private","mobile_business","mobile_private","fax_business","fax_private"); + static boolean number(String value){ + String raw=value.replace(" ","").replaceAll("^[\\x09-\\x0D\\x20\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+|[\\x09-\\x0D\\x20\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+$",""); + return raw.isEmpty()||raw.matches("[+-]?Infinity|0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)(?:[eE][+-]?[0-9]+)?"); + } + static void validate(Map definition,String value){ + if(PHONE.contains(text(definition,"type"))&&!number(value))throw bad(org.jsoup.Jsoup.parse(text(definition,"label")).text()+": Bitte eine Nummer eingeben; Leerzeichen sind erlaubt."); + if(!MAIL.contains(text(definition,"type"))||value.isEmpty())return; + for(String item:value.split(",",-1))if(!address(item.replaceAll("^ +| +$",""))) + throw bad(org.jsoup.Jsoup.parse(text(definition,"label")).text()+": Bitte gültige E-Mail-Adressen eingeben; mehrere Adressen mit Komma trennen."); + } + static boolean address(String value){ + int at=value.indexOf('@');if(at<=0||at!=value.lastIndexOf('@'))return false; + for(String part:value.substring(0,at).split("\\.",-1))if(!part.matches("[A-Za-z0-9_+\\-]+"))return false; + String domain=value.substring(at+1); + if(domain.startsWith("[")&&domain.endsWith("]")){ + String[] octets=domain.substring(1,domain.length()-1).split("\\.",-1); + return octets.length==4&&Arrays.stream(octets).allMatch(s->s.matches("(?:[01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])")); + } + String[] labels=domain.split("\\.",-1);if(labels.length<2)return false; + for(String label:labels)if(label.isEmpty()||label.startsWith("-")||label.endsWith("-")||!label.matches("[A-Za-z0-9-]+"))return false; + String last=labels[labels.length-1];int hyphen=last.indexOf('-'); + return (hyphen<0?last:last.substring(0,hyphen)).matches("[A-Za-z]+"); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/FormFieldSupport.java b/backend/src/main/java/de/stadtbote/votian/FormFieldSupport.java new file mode 100644 index 0000000..45f892a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FormFieldSupport.java @@ -0,0 +1,84 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Data-only counterparts of inc_metafield.inc.php selection and reference fields. */ +final class FormFieldSupport { + final DatabaseService db; + FormFieldSupport(DatabaseService db){this.db=db;} + static final Set EXTRA=Set.of("phone","mobile","fax","email","mail","mobile_business","mobile_private","fax_business","fax_private","mail_business","mail_private","url_business","url_private","link","image","checkboxes_hq"); + record Choice(String table,String value,String label,String sort,boolean all){} + static final Map> COLUMNS=Map.of( + "cs",List.of("cs_id","cs_eid","cmp_comp","cmp_comp2"),"cr",List.of("cr_id","cr_eid","cmp_comp","cmp_comp2"), + "crvh",List.of("crvh_id","crvh_sid","crvh_vh_sign"),"hq",List.of("hq_id","hq_mnemonic","hq_name"), + "vht",List.of("mt_sort","mt_value"),"grp",List.of("grp_id","grp_name"),"br",List.of("br_id","br_name")); + static Choice choice(String type){ + if(type.equals("co2cs"))return new Choice("cs","cs_id","cs_eid","cs_eid",false); + if(!type.startsWith("select:")&&!type.startsWith("button:"))return null; + String[] p=type.split(":",-1);if(p.length<2||p.length>7||!COLUMNS.containsKey(p[1]))return null; + if(p[0].equals("button")&&!Set.of("cs","cr","crvh").contains(p[1]))return null; + var columns=COLUMNS.get(p[1]);var names=new ArrayList(); + for(int i=2;i<=4;i++){String v=p.length>i?p[i]:"";if(v.isBlank())v=columns.get(i==2||i==4&&p[1].equals("vht")?0:1);String prefix=p[1]+".";if(v.startsWith(prefix))v=v.substring(prefix.length());if(!columns.contains(v))return null;names.add(v);} + if(p.length>5&&!Set.of("","0","1","distinct","DISTINCT").contains(p[5]))return null; + if(p.length>6&&!Set.of("","hq_all","hq_current").contains(p[6]))return null; + return new Choice(p[1],names.get(0),names.get(1),names.get(2),p.length>6&&p[6].equals("hq_all")); + } + Set sources(List> definitions){var result=EnumSet.noneOf(DatabaseService.FormReadSource.class);for(var d:definitions){String type=text(d,"type");if(type.equals("checkboxes_hq"))result.add(DatabaseService.FormReadSource.HEADQUARTERS);var c=choice(type);if(c==null)continue;result.add(switch(c.table()){case "cs"->DatabaseService.FormReadSource.CUSTOMER;case "cr"->DatabaseService.FormReadSource.COURIER;case "crvh"->DatabaseService.FormReadSource.VEHICLE;case "hq"->DatabaseService.FormReadSource.HEADQUARTERS;case "grp"->DatabaseService.FormReadSource.GROUP;case "vht"->DatabaseService.FormReadSource.VEHICLE_TYPE;default->DatabaseService.FormReadSource.BRANCH;});if(c.all()&&Set.of("cs","cr","crvh").contains(c.table()))result.add(DatabaseService.FormReadSource.DIRECTORY_RIGHTS);}return result;} + static boolean systemSnapshot(String type){return type.startsWith("sys:")&&MasterForms.SYSTEM.contains(type.substring(4))&&!Set.of("sys:current_user","sys:current_headquarters_company_logo").contains(type);} + static Map defaults(Map stored,Map suggested){var result=new LinkedHashMap<>(stored);for(var entry:suggested.entrySet())if(result.getOrDefault(entry.getKey(),"").isBlank())result.put(entry.getKey(),entry.getValue());return result;} + String currentUser(LegacyUser u){var rows=db.queryForList("SELECT usr_firstname,usr_name FROM user WHERE usr_id=?",u.userId());if(rows.size()!=1)return u.username();String name=(text(rows.getFirst(),"usr_firstname")+" "+text(rows.getFirst(),"usr_name")).strip();return name.isEmpty()?u.username():name;} + static boolean editable(String type){return MasterForms.EDITABLE.contains(type)||EXTRA.contains(type)||choice(type)!=null;} + static boolean generated(String type){return type.equals("author")||type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*");} + static String column(String table,String column){return (column.startsWith("cmp_")?"cmp":table)+"."+column;} + List> headquarters(long hq){return db.queryForList("SELECT DISTINCT h.hq_id AS value,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 ORDER BY h.hq_mnemonic,h.hq_id",hq).stream().>map(r->fields("value",text(r,"value"),"label",text(r,"label"))).toList();} + List> choices(LegacyUser u,long hq,Choice c,String query,String exact){ + if(query==null||query.length()>100||exact!=null&&exact.length()>65535)throw bad("Suchbegriff ist zu lang."); + var args=new ArrayList();String from,where="1=1"; + switch(c.table()){ + case "cs","cr","crvh"->{int right=c.table().equals("cs")?0:1;u.require(right);var hqs=c.all()?MasterDirectoryScope.access(db,u,right).branches().stream().map(r->id(r,"id")).toList():List.of(hq); + if(c.table().equals("crvh")){from="couriervehicle crvh JOIN courier cr ON cr.cr_id=crvh.cr_id";where="cr.hq_id IN ("+CrmScope.marks(hqs.size())+")";} + else{String t=c.table();from=(t.equals("cs")?"customer cs":"courier cr")+" JOIN company cmp ON cmp.cmp_id="+t+".cmp_id";where=t+".hq_id IN ("+CrmScope.marks(hqs.size())+") AND cmp.cmp_authenticated=1 AND cmp.cmp_visible=1";} + args.addAll(hqs); + } + case "hq"->{from="headquarters hq JOIN mandatorheadquarters m ON m.hq_id=hq.hq_id";where="m.md_id=?";args.add(mandator(db,u));} + case "grp"->{from="groups grp JOIN headquarters h ON h.hq_id=grp.hq_id";where="grp.md_id=?";args.add(mandator(db,u));} + case "vht"->{from="metatype vht";where="vht.mt_type='vehicletype'";} + case "br"->from="branch br"; + default->throw bad("Unbekannte Formularauswahl."); + } + String value=column(c.table(),c.value()),label=column(c.table(),c.label()),sort=column(c.table(),c.sort()); + if(exact!=null){where+=" AND CAST("+value+" AS CHAR)=?";args.add(exact);} + else if(!query.isBlank()){String q="%"+query.replace("!","!!").replace("%","!%").replace("_","!_")+"%";where+=" AND (CAST("+value+" AS CHAR) LIKE ? ESCAPE '!' OR "+label+" LIKE ? ESCAPE '!')";args.add(q);args.add(q);} + return db.queryForList("SELECT DISTINCT CAST("+value+" AS CHAR) AS value,"+label+" AS label,"+sort+" AS sorting FROM "+from+" WHERE "+where+" ORDER BY sorting,value,label LIMIT 101",args.toArray()).stream().>map(r->fields("value",text(r,"value"),"label",text(r,"label"))).toList(); + } + Map options(LegacyUser u,long hq,String type,String query,String selected){var c=choice(type);if(c==null)throw bad("Dieses Feld hat keine Auswahlliste.");var rows=choices(u,hq,c,query,null);var items=new ArrayList<>(rows.stream().limit(100).toList());if(selected!=null&&!selected.isEmpty()&&items.stream().noneMatch(r->text(r,"value").equals(selected)))items.addAll(choices(u,hq,c,"",selected));return fields("items",items,"more",rows.size()>100);} + Map definition(LegacyUser u,long hq,Map def){var result=new LinkedHashMap<>(def);String type=text(def,"type");result.put("editable",editable(type));var c=choice(type);if(c!=null)result.put("lookup",true);if(type.equals("checkboxes_hq"))result.put("choices",headquarters(hq));return result;} + String validate(LegacyUser u,long hq,Map def,String value,String before){ + String type=text(def,"type");if(type.equals("time")&&!value.isEmpty()&&!value.matches("(?:[01][0-9]|2[0-3]):[0-5][0-9]"))throw bad("Uhrzeit bitte als HH:MM eingeben.");value=MasterForms.validate(def,value);FormContactValues.validate(def,value);if(value.isEmpty()&&!type.equals("checkboxes_hq"))return value; + var c=choice(type);if(c!=null){if(choices(u,hq,c,"",value).isEmpty())throw bad(text(def,"label")+": Auswahl ist nicht mehr verfügbar oder nicht freigegeben.");} + if(type.equals("checkboxes_hq")){var selected=ids(value);var allowed=headquarters(hq).stream().map(r->text(r,"value")).toList();var prior=ids(before);if(selected.stream().anyMatch(v->!allowed.contains(v)&&!prior.contains(v)))throw bad("Niederlassung gehört nicht zum Mandanten.");if(prior.stream().anyMatch(v->!allowed.contains(v)&&!selected.contains(v)))throw bad("Nicht mehr verfügbare Niederlassungszuordnungen müssen erhalten bleiben.");return String.join(",",selected);} + if(type.equals("image"))NewsletterTemplate.imageParts(value); + if(type.equals("link"))link(value); + return value; + } + static List ids(String value){if(value.isEmpty())return List.of();if(!value.matches("[1-9][0-9]{0,9}(?:,[1-9][0-9]{0,9}){0,99}"))throw bad("Ungültige Niederlassungsauswahl.");return Arrays.stream(value.split(",")).distinct().sorted(Comparator.comparingLong(Long::parseLong)).toList();} + static String[] link(String value){String[] parts=value.split(",",3);String url=parts[0].strip();if(url.startsWith("//"))url="https:"+url;if(!url.matches("(?i)^https?://.*")||MasterForms.safeLink("",url).isEmpty())throw bad("Link bitte als vollständige http(s)-Adresse eingeben.");return new String[]{url,parts.length>1&&!parts[1].isBlank()?parts[1].strip():url};} + static Map generated(List> defs,Map values,long author,boolean save){var next=new LinkedHashMap<>(values);var byKey=new HashMap>();for(var d:defs){if(byKey.put(id(d,"keyId"),d)!=null)throw bad("Doppelte Felddefinitionen.");if(save&&text(d,"type").equals("author")&&next.getOrDefault(id(d,"id"),"").isBlank())next.put(id(d,"id"),Long.toString(author));}var done=new HashSet();for(var d:defs)resolve(d,byKey,next,new HashSet<>(),done);return next;} + private static String resolve(Map d,Map> defs,Map values,Set path,Set done){long key=id(d,"keyId"),field=id(d,"id");String type=text(d,"type");if(!type.startsWith("ref:")||!generated(type)||done.contains(key))return values.getOrDefault(field,"");if(path.size()>=100||!path.add(key))throw bad("Zyklische oder zu tiefe Feldreferenz.");var parts=new ArrayList();for(String ref:type.substring(4).split(":")){var child=defs.get(Long.parseLong(ref));if(child==null)throw bad("Referenziertes Feld "+ref+" fehlt in diesem Formular.");parts.add(resolve(child,defs,values,path,done));}String result=String.join(" ",parts);if(result.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65535)throw bad("Zusammengesetztes Feld ist zu lang.");values.put(field,result);path.remove(key);done.add(key);return result;} + private String customerDisplay(LegacyUser u,long hq,String value){ + if(!u.hasRight(0)||!value.matches("[1-9][0-9]{0,9}"))return value; + var rows=db.queryForList("SELECT cs.cs_eid,cmp.cmp_comp FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=? AND cmp.cmp_authenticated=1 AND cmp.cmp_visible=1",Long.parseLong(value),hq); + if(rows.size()>1)throw bad("Mehrdeutiger Kundenverweis im Formular."); + if(rows.isEmpty())return value; + var row=rows.getFirst();String number=text(row,"cs_eid").strip(),company=text(row,"cmp_comp").strip(); + return (number.isEmpty()?value:number)+" (ExtID)"+(company.isEmpty()?"":" ["+company+"]"); + } + Map display(LegacyUser u,List> defs,Map values,long hq){var result=new LinkedHashMap<>(values);for(var d:defs){String value=values.getOrDefault(id(d,"id"),"");if(value.isBlank())continue;String type=text(d,"type");if(type.equals("co2cs"))result.put(id(d,"id"),customerDisplay(u,hq,value));if(type.equals("author")&&value.matches("[1-9][0-9]{0,9}")){var rows=db.queryForList("SELECT u.usr_firstname,u.usr_name,h.hq_mnemonic FROM user u LEFT JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_id=?",Long.parseLong(value));if(rows.size()==1){var row=rows.getFirst();result.put(id(d,"id"),(text(row,"usr_firstname")+" "+text(row,"usr_name")).strip()+" ["+text(row,"hq_mnemonic")+"]");}} + if(type.equals("checkboxes_hq")){var names=headquarters(hq);result.put(id(d,"id"),String.join(", ",ids(value).stream().map(v->names.stream().filter(r->text(r,"value").equals(v)).map(r->text(r,"label")).findFirst().orElse("Niederlassung "+v)).toList()));}} + return result; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/FormTemplateRules.java b/backend/src/main/java/de/stadtbote/votian/FormTemplateRules.java new file mode 100644 index 0000000..2df35ad --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/FormTemplateRules.java @@ -0,0 +1,46 @@ +package de.stadtbote.votian; + +import org.jsoup.Jsoup; +import java.util.*; +import java.util.regex.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Recognizes the legacy save-only wrapper and checkbox-sum grammar without executing JavaScript. */ +final class FormTemplateRules { + record Term(long field,int weight){} + record Calculation(long target,List terms){} + record Rules(List calculations,String warning){ + boolean supported(){return warning.isEmpty();} + boolean target(long field){return calculations.stream().anyMatch(c->c.target()==field);} + Map apply(Map values){var next=new LinkedHashMap<>(values);for(var c:calculations){int sum=0;for(var t:c.terms())if(values.getOrDefault(t.field(),"").equals("1"))sum=Math.addExact(sum,t.weight());next.put(c.target(),Integer.toString(sum));}return next;} + void requireSupported(){if(!supported())throw bad(warning);} + } + static final Pattern START=Pattern.compile("^function\\s+formularFinishPage\\s*\\(\\s*\\)\\s*\\{\\s*var\\s+specialSum\\s*=\\s*0\\s*;"); + static final Pattern ADD=Pattern.compile("^if\\s*\\(\\s*document\\.forms\\[0]\\.f_mtfv_([0-9]+)_([0-9]+)\\.checked\\s*==\\s*true\\s*\\)\\s*\\{\\s*specialSum\\s*\\+=\\s*([0-9]+)\\s*}\\s*;"); + static final Pattern ASSIGN=Pattern.compile("^document\\.forms\\[0]\\.f_mtfv_([0-9]+)_([0-9]+)\\.value\\s*=\\s*specialSum\\s*;"); + static final Pattern RESET=Pattern.compile("^specialSum\\s*=\\s*0\\s*;"); + static final Pattern END=Pattern.compile("^finishPage\\s*\\(\\s*\\)\\s*;\\s*}\\s*$"); + static final Pattern SAVE_ONLY=Pattern.compile("^function\\s+formularFinishPage\\s*\\(\\s*\\)\\s*\\{\\s*finishPage\\s*\\(\\s*\\)\\s*;\\s*}\\s*$"); + static Rules read(String template,long category,List> definitions){ + var scripts=Jsoup.parse(template).select("script").stream().map(e->e.data()).filter(s->s.contains("formularFinishPage")).toList(); + if(scripts.isEmpty())return new Rules(List.of(),""); + try{ + if(scripts.size()!=1)throw new IllegalArgumentException(); + String source=scripts.getFirst().replace("","").replaceAll("(?m)//[^\\r\\n]*","").strip(); + if(SAVE_ONLY.matcher(source).matches())return new Rules(List.of(),""); + var start=START.matcher(source);if(!start.find())throw new IllegalArgumentException();source=source.substring(start.end()).strip(); + var byKey=new HashMap>();for(var d:definitions)if(byKey.put(id(d,"keyId"),d)!=null)throw new IllegalArgumentException(); + var terms=new ArrayList();var calculations=new ArrayList(); + for(int count=0;count<1000;count++){ + if(END.matcher(source).matches()){if(calculations.isEmpty())throw new IllegalArgumentException();return new Rules(List.copyOf(calculations),"");} + var add=ADD.matcher(source);var assign=ASSIGN.matcher(source);var reset=RESET.matcher(source); + if(add.find()){if(Long.parseLong(add.group(1))!=category)throw new IllegalArgumentException();var d=byKey.get(Long.parseLong(add.group(2)));int weight=Integer.parseInt(add.group(3));if(d==null||!text(d,"type").equals("checkbox")||weight<1||weight>1000)throw new IllegalArgumentException();terms.add(new Term(id(d,"id"),weight));source=source.substring(add.end()).strip();} + else if(assign.find()){if(Long.parseLong(assign.group(1))!=category||terms.isEmpty())throw new IllegalArgumentException();var d=byKey.get(Long.parseLong(assign.group(2)));if(d==null||!Set.of("varchar","number","integer","float","float_2").contains(text(d,"type")))throw new IllegalArgumentException();long target=id(d,"id");if(calculations.stream().anyMatch(c->c.target()==target))throw new IllegalArgumentException();calculations.add(new Calculation(target,List.copyOf(terms)));source=source.substring(assign.end()).strip();} + else if(reset.find()){terms.clear();source=source.substring(reset.end()).strip();} + else throw new IllegalArgumentException(); + } + throw new IllegalArgumentException(); + }catch(IllegalArgumentException e){return new Rules(List.of(),"Die Vorlage enthält eine noch nicht unterstützte Berechnungsregel. Das Formular bleibt bis zum Abgleich schreibgeschützt.");} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/HeaderActivity.java b/backend/src/main/java/de/stadtbote/votian/HeaderActivity.java new file mode 100644 index 0000000..a5eb80a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HeaderActivity.java @@ -0,0 +1,76 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +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; + +/** PHP menu's configured employee presence flags; opening the roster never changes a flag. */ +@RestController +@RequestMapping("/api/account/activity") +class HeaderActivity { + final DatabaseService db;final BusinessSupport support; + HeaderActivity(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + record Change(String requestId,String revision,String key,boolean active){} + record State(Map actor,List> settings,Map labels,List> flags,boolean enabled){} + @GetMapping Object get(Authentication a){return view(ApiController.user(a));} + @PutMapping Object put(Authentication a,@RequestBody Change c){return save(ApiController.user(a),c);} + static void access(LegacyUser u){if(u.accountType()!=1||u.employeeId()<=0)throw new AccessDeniedException("Diese Statusanzeige ist nur für Mitarbeiterkonten verfügbar.");} + static Map labels(String value){ + var result=new LinkedHashMap(); + for(String pair:value.split("\\|",-1)){var parts=pair.split("=",-1);if(parts.length<2||parts[1].isEmpty())continue; + String key=parts[0];if(!key.matches("[a-zA-Z0-9_]{1,40}")||key.equalsIgnoreCase("newsticker")||parts[1].length()>100)throw bad("Die konfigurierten Statusanzeigen sind ungültig."); + if(result.keySet().stream().anyMatch(k->!k.equals(key)&&k.equalsIgnoreCase(key)))throw bad("Die Statuskennungen sind nicht eindeutig."); + result.put(key,parts[1]); + } + if(result.size()>30)throw bad("Es sind zu viele Statusanzeigen konfiguriert.");return result; + } + static String setting(List> rows,String key,long hq){ + var own=rows.stream().filter(r->id(r,"hq_id")==hq&&text(r,"par_key").equals(key)).toList(); + var global=rows.stream().filter(r->id(r,"hq_id")==0&&text(r,"par_key").equals(key)).toList(); + if(own.size()>1||global.size()>1)throw bad("Der Statusparameter ist mehrfach vorhanden: "+key); + String local=own.isEmpty()?"":text(own.getFirst(),"par_value");return !local.isEmpty()?local:global.isEmpty()?"":text(global.getFirst(),"par_value"); + } + State state(LegacyUser u){ + var actor=one(db.queryForList("SELECT employee.emp_id,user.usr_id,user.hq_id,user.usr_type FROM employee JOIN user ON user.usr_id=employee.usr_id WHERE employee.emp_id=? AND user.usr_id=? AND user.hq_id=? AND user.usr_type=1 AND employee.csc_id=0",u.employeeId(),u.userId(),u.originalHeadquartersId())); + var settings=db.queryForList("SELECT par_id,hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ('MENU_USER_STATE_ITEMS','MENU_USER_STATE_ITEMLIST_OUTPUT') ORDER BY par_id",u.headquartersId()); + boolean enabled=setting(settings,"MENU_USER_STATE_ITEMS",u.headquartersId()).equals("1"); + var labels=enabled?labels(setting(settings,"MENU_USER_STATE_ITEMLIST_OUTPUT",u.headquartersId())):Map.of(); + var flags=new ArrayList>(); + for(String key:labels.keySet()){ + var found=db.queryForList("SELECT gdc_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='usr' AND gdc_obj_id=? AND gdc_gen_fieldname=? ORDER BY gdc_id",u.userId(),"usr_state_"+key); + if(found.size()>1||!found.isEmpty()&&!Set.of("","0","1").contains(text(found.getFirst(),"gdc_content")))throw bad("Der persönliche Status ist nicht eindeutig. Bitte den Bestand prüfen."); + flags.addAll(found); + } + return new State(actor,settings,labels,flags,enabled); + } + static String revisionOf(State s){return revision(List.of(s.actor(),s.settings(),s.flags()));} + static boolean active(State s,String key){return s.flags().stream().anyMatch(r->text(r,"gdc_gen_fieldname").equalsIgnoreCase("usr_state_"+key)&&text(r,"gdc_content").equals("1"));} + Map present(LegacyUser u,State s){ + var items=new ArrayList>(); + for(var label:s.labels().entrySet()){ + var people=db.queryForList("SELECT DISTINCT user.usr_id AS id,user.usr_firstname AS firstName,user.usr_name AS lastName FROM user JOIN genericdatacontainer ON genericdatacontainer.gdc_obj_id=user.usr_id WHERE user.usr_type=1 AND user.hq_id=? AND genericdatacontainer.gdc_obj_type='usr' AND genericdatacontainer.gdc_gen_fieldname=? AND genericdatacontainer.gdc_content='1' ORDER BY user.usr_name,user.usr_firstname,user.usr_id LIMIT 1001",u.headquartersId(),"usr_state_"+label.getKey()); + items.add(fields("key",label.getKey(),"label",label.getValue(),"active",active(s,label.getKey()),"people",people.stream().limit(1000).toList(),"truncated",people.size()>1000)); + } + return fields("enabled",s.enabled(),"headquarters",u.headquartersId(),"canChange",u.headquartersId()==u.originalHeadquartersId(),"revision",revisionOf(s),"items",items); + } + Map view(LegacyUser u){access(u);return db.readTransaction(()->present(u,state(u)));} + Map save(LegacyUser u,Change c){ + access(u);if(u.headquartersId()!=u.originalHeadquartersId())throw new AccessDeniedException("Der eigene Status kann nur in der Heimatniederlassung geändert werden."); + if(c==null||c.key()==null||!c.key().matches("[a-zA-Z0-9_]{1,40}")||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Die Statusänderung ist ungültig."); + BusinessSupport.token(c.requestId());support.transactional("genericdatacontainer"); + return db.employeeActivityWriteTransaction(()->{ + var s=state(u);if(support.receipt(u,c.requestId(),c)!=null)return present(u,s); + if(!s.enabled()||!s.labels().containsKey(c.key()))throw bad("Diese Statusanzeige ist nicht mehr freigegeben. Bitte neu laden."); + if(!revisionOf(s).equals(c.revision()))throw bad("Der Status oder die Konfiguration wurde inzwischen geändert. Bitte neu laden."); + String key="usr_state_"+c.key();var found=s.flags().stream().filter(r->text(r,"gdc_gen_fieldname").equalsIgnoreCase(key)).toList(); + if(found.isEmpty())db.insert("genericdatacontainer",fields("gdc_obj_type","usr","gdc_obj_id",u.userId(),"gdc_gen_fieldname",key,"gdc_content",c.active()?"1":"0","gdc_context","")); + else db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context='' WHERE gdc_id=?",c.active()?"1":"0",id(found.getFirst(),"gdc_id")); + support.receipt(u,c.requestId(),c,0);return present(u,state(u)); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java b/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java new file mode 100644 index 0000000..2cfb7ee --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HeadquartersSettings.java @@ -0,0 +1,132 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +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; + +/** The six active sections of hq_admin.php; no generic parameter or script editor. */ +@RestController +@RequestMapping("/api/administration/headquarters-settings") +class HeadquartersSettings { + enum Section { ADDRESS, SUPPORT, HOTLINE, GLN, DUNS, VEHICLES } + record Field(String key,String label,int maxLength) {} + static final Map LABELS=Map.of(Section.ADDRESS,"Standortadressen",Section.SUPPORT,"Supportkontakte",Section.HOTLINE,"BWV-Hotline",Section.GLN,"GLN",Section.DUNS,"DUNS",Section.VEHICLES,"Fahrzeugklassen"); + static final Map> FIELDS=Map.of( + Section.ADDRESS,List.of(new Field("cmp_comp","Firma 1",50),new Field("cmp_comp2","Firma 2",50),new Field("cmp_comp3","Firma 3",50),new Field("cmp_comp4","Firma 4",50),new Field("ad_street","Straße",50),new Field("cmp_hsno","Hausnummer",10),new Field("ad_zipcode","PLZ",10),new Field("ad_city","Ort",50)), + Section.SUPPORT,List.of(new Field("hq_sales_email","Vertrieb · E-Mail",250),new Field("hq_sales_phone","Vertrieb · Telefon",20),new Field("hq_cr_care_email","Unternehmerbetreuung · E-Mail",250),new Field("hq_cr_care_phone","Unternehmerbetreuung · Telefon",20)), + Section.HOTLINE,List.of(new Field("value","Telefonnummer",250)),Section.GLN,List.of(new Field("value","GLN",20)),Section.DUNS,List.of(new Field("value","DUNS",10))); + record Access(LegacyUser user,long mandator,List> branches) {} + record Snapshot(Access access,Section section,long target,Map headquarters,Map company,Map address,List> parameters,List> references,Map values,List warnings,boolean writable) {} + record Draft(String revision,Map values) {} + record Command(String requestId,Draft draft,String fingerprint) {} + record Preview(String fingerprint,Map before,Map after,List warnings) {} + final DatabaseService db; final BusinessSupport support; + HeadquartersSettings(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping Object get(Authentication auth,@RequestParam(required=false) Section section){return view(ApiController.user(auth),section);} + @PostMapping("/{section}/{target}/preview") Object preview(Authentication auth,@PathVariable Section section,@PathVariable long target,@RequestBody Draft draft){return preview(ApiController.user(auth),section,target,draft);} + @PostMapping("/{section}/{target}") Object save(Authentication auth,@PathVariable Section section,@PathVariable long target,@RequestBody Command command){return save(ApiController.user(auth),section,target,command);} + + Access access(LegacyUser actor){ + if(actor.headquartersId()!=actor.originalHeadquartersId())throw new AccessDeniedException("Niederlassungseinstellungen bitte in der eigenen Arbeitsniederlassung öffnen."); + var u=new WorkspaceHeadquarters(db).select(actor,actor.originalHeadquartersId()); + var mappings=db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=?",u.headquartersId()); + if(mappings.size()!=1||id(mappings.getFirst(),"md_id")<=0)throw bad("Die Mandantenzuordnung ist nicht eindeutig."); + long md=id(mappings.getFirst(),"md_id"); + var branches=db.queryForList("SELECT headquarters.hq_id,headquarters.hq_name,headquarters.hq_mnemonic FROM headquarters JOIN mandatorheadquarters ON mandatorheadquarters.hq_id=headquarters.hq_id WHERE mandatorheadquarters.md_id=? ORDER BY headquarters.hq_mnemonic,headquarters.hq_id",md); + if(branches.stream().map(r->id(r,"hq_id")).distinct().count()!=branches.size())throw bad("Die Niederlassungszuordnung ist nicht eindeutig."); + for(var h:branches)if(db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=?",id(h,"hq_id")).size()!=1)throw bad("Die Niederlassungszuordnung ist nicht eindeutig."); + return new Access(u,md,branches); + } + static boolean readable(Access a,Section s){return s==Section.VEHICLES||(s==Section.ADDRESS||s==Section.SUPPORT?a.user().hasRight(47)||a.user().hasRight(10):a.user().hasRight(45));} + static boolean editable(Access a,Section s){return s!=Section.VEHICLES&&a.user().hasRight(47)&&(s==Section.ADDRESS||s==Section.SUPPORT||a.user().hasRight(45));} + static String parameter(Section s){return switch(s){case HOTLINE->"BWV_PHONE_NO";case GLN->"GLN_HQ_ALL";case DUNS->"DUNS_HQ_ALL";default->throw bad("Ungültiger Parameterbereich.");};} + static String label(Access a,long target){return target==0?"Bundesweit":a.branches().stream().filter(h->id(h,"hq_id")==target).map(h->text(h,"hq_mnemonic")+" · "+text(h,"hq_name")).findFirst().orElse("");} + Snapshot snapshot(Access a,Section s,long target){ + if(!readable(a,s))throw denied(); + if(s==Section.VEHICLES||target<0||target==0&&(s==Section.ADDRESS||s==Section.SUPPORT)||target>0&&a.branches().stream().noneMatch(h->id(h,"hq_id")==target))throw denied(); + var h=target==0?Map.of():one(db.queryForList("SELECT hq_id,cmp_id,hq_cr_care_phone,hq_cr_care_email,hq_sales_phone,hq_sales_email,hq_gln,hq_duns FROM headquarters WHERE hq_id=?",target)); + Map company=Map.of(),address=Map.of();List> params=List.of(),references=List.of();var warnings=new ArrayList();boolean writable=editable(a,s);var values=new LinkedHashMap(); + if(s==Section.ADDRESS){ + var companies=db.queryForList("SELECT cmp_id,cmp_comp,cmp_comp2,cmp_comp3,cmp_comp4,cmp_hsno,ad_id FROM company WHERE cmp_id=?",id(h,"cmp_id")); + if(companies.size()!=1){warnings.add("Die Firmenzuordnung fehlt oder ist mehrdeutig. Bitte zuerst den Bestand klären.");writable=false;} + else { + company=companies.getFirst();var addresses=db.queryForList("SELECT * FROM address WHERE ad_id=?",id(company,"ad_id")); + if(addresses.size()>1)throw bad("Die Adresse ist mehrdeutig.");if(!addresses.isEmpty())address=addresses.getFirst(); + references=new ArrayList<>(); + references.addAll(db.queryForList("SELECT hq_id AS id,'headquarters' AS kind FROM headquarters WHERE cmp_id=? AND hq_id<>?",id(company,"cmp_id"),target)); + references.addAll(db.queryForList("SELECT cs_id AS id,'customer' AS kind FROM customer WHERE cmp_id=?",id(company,"cmp_id"))); + references.addAll(db.queryForList("SELECT cr_id AS id,'courier' AS kind FROM courier WHERE cmp_id=?",id(company,"cmp_id"))); + if(!references.isEmpty()){warnings.add("Die Firma wird auch von anderen Stammdaten verwendet. Änderungen sind bis zur Klärung gesperrt.");writable=false;} + if(!address.isEmpty())references.addAll(db.queryForList("SELECT cmp_id AS id,'address' AS kind FROM company WHERE ad_id=? AND cmp_id<>? ORDER BY cmp_id",id(address,"ad_id"),id(company,"cmp_id"))); + if(references.stream().anyMatch(r->text(r,"kind").equals("address")))warnings.add("Diese Adresse wird gemeinsam verwendet. Beim Ändern der Anschrift erhält die Niederlassung eine eigene Kopie; andere Firmen behalten ihre Adresse."); + if(address.isEmpty())warnings.add("Beim Speichern wird die bisher fehlende Adresse angelegt."); + } + for(var f:FIELDS.get(s))values.put(f.key(),text(f.key().startsWith("ad_")?address:company,f.key())); + }else if(s==Section.SUPPORT){for(var f:FIELDS.get(s))values.put(f.key(),text(h,f.key()));} + else if(s==Section.HOTLINE||target==0){ + params=db.queryForList("SELECT par_id,par_key,par_value,par_text,md_id,hq_id,emp_id FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=? ORDER BY par_id",target,parameter(s)); + if(params.size()>1||params.stream().anyMatch(p->id(p,"md_id")!=0&&id(p,"md_id")!=a.mandator()))throw bad("Der Parameter ist mehrfach vorhanden oder einem anderen Mandanten zugeordnet."); + String value=params.isEmpty()?"":text(params.getFirst(),"par_value");values.put("value",value); + if(target==0)warnings.add("Diese Vorgabe gilt bundesweit und wird auch von der Altanwendung verwendet."); + else if(s==Section.HOTLINE&&value.isBlank())warnings.add("Ohne eigene Telefonnummer verwenden PHP-Verbraucher gegebenenfalls die bundesweite Hotline."); + }else values.put("value",text(h,s==Section.GLN?"hq_gln":"hq_duns")); + return new Snapshot(a,s,target,h,company,address,params,references,values,List.copyOf(warnings),writable); + } + static String version(Snapshot s){return revision(List.of(s.access().mandator(),s.access().branches(),s.access().user().rights(),s.section(),s.target(),s.headquarters(),s.company(),s.address(),s.parameters(),s.references()));} + Map view(LegacyUser actor,Section selected){return db.readTransaction(()->{ + var a=access(actor);var sections=Arrays.stream(Section.values()).filter(s->readable(a,s)).toList();Section s=selected==null?sections.getFirst():selected;if(!sections.contains(s))throw denied(); + var items=new ArrayList>();var counts=new ArrayList>(); + if(s==Section.VEHICLES){ + for(var h:a.branches())for(var row:db.queryForList("SELECT couriervehicle.vht_id AS type,COUNT(*) AS count FROM couriervehicle JOIN courier ON courier.cr_id=couriervehicle.cr_id WHERE courier.hq_id=? GROUP BY couriervehicle.vht_id ORDER BY couriervehicle.vht_id",id(h,"hq_id"))){ + var names=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=? ORDER BY mt_id",id(row,"type")); + counts.add(fields("headquartersId",id(h,"hq_id"),"headquartersLabel",label(a,id(h,"hq_id")),"type",id(row,"type"),"label",names.size()==1?text(names.getFirst(),"mt_value"):"Fahrzeugklasse "+id(row,"type"),"count",id(row,"count"))); + } + }else{ + var targets=new ArrayList();if(s!=Section.ADDRESS&&s!=Section.SUPPORT)targets.add(0L);a.branches().forEach(h->targets.add(id(h,"hq_id"))); + for(long target:targets){var snap=snapshot(a,s,target);items.add(fields("id",target,"label",label(a,target),"revision",version(snap),"values",snap.values(),"canSave",snap.writable(),"warnings",snap.warnings()));} + } + return fields("section",s,"sections",sections.stream().map(x->fields("id",x,"label",LABELS.get(x))).toList(),"fields",FIELDS.getOrDefault(s,List.of()),"items",items,"vehicleCounts",counts); + });} + 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 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){ + if(!s.writable())throw denied();if(!version(s).equals(d.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Niederlassungseinstellungen wurden zwischenzeitlich geändert. Bitte neu laden."); + var after=normalize(s.section(),d);return new Preview(revision(List.of(s.access().user().userId(),s.access().user().originalHeadquartersId(),s.section(),s.target(),d,after)),s.values(),after,s.warnings()); + } + static void requireEdit(LegacyUser actor,Section s){actor.require(47);if(s!=Section.ADDRESS&&s!=Section.SUPPORT)actor.require(45);} + Preview preview(LegacyUser actor,Section s,long target,Draft d){requireEdit(actor,s);normalize(s,d);return db.readTransaction(()->prepare(snapshot(access(actor),s,target),d));} + Map save(LegacyUser actor,Section s,long target,Command c){ + requireEdit(actor,s);if(c==null)throw bad("Änderung fehlt.");BusinessSupport.token(c.requestId());normalize(s,c.draft());if(c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte die Änderung zuerst prüfen."); + if(!editable(access(actor),s))throw denied();support.transactional("headquarters","company","address","parameter","genericdatacontainer"); + return db.headquartersSettingsWriteTransaction(()->{ + var snap=snapshot(access(actor),s,target);if(!snap.writable())throw denied();var intent=List.of("HEADQUARTERS_SETTINGS",actor.originalHeadquartersId(),s,target,c.draft(),c.fingerprint());var previous=support.receipt(snap.access().user(),c.requestId(),intent);if(previous!=null)return fields("changed",previous,"repeated",true); + var p=prepare(snap,c.draft());if(!p.fingerprint().equals(c.fingerprint()))throw bad("Die Vorschau passt nicht mehr zu diesen Angaben.");boolean changed=!p.before().equals(p.after()); + if(changed){apply(snap,p.after());support.audit(snap.access().user(),0,0,0,s==Section.ADDRESS||s==Section.SUPPORT?199:196,"HQ="+target+" | SECTION="+s+" | BEFORE="+support.encode(p.before())+" | AFTER="+support.encode(p.after()),c.requestId());} + support.receipt(snap.access().user(),c.requestId(),intent,changed?1:0);return fields("changed",changed?1:0,"repeated",false); + }); + } + void apply(Snapshot s,Map values){ + if(s.section()==Section.ADDRESS){ + var company=new LinkedHashMap();var address=new LinkedHashMap();values.forEach((k,v)->(k.startsWith("ad_")?address:company).put(k,v)); + boolean addressChanged=s.address().isEmpty()||address.entrySet().stream().anyMatch(e->!Objects.equals(e.getValue(),text(s.address(),e.getKey()))); + if(addressChanged){ + if(s.address().isEmpty()||s.references().stream().anyMatch(r->text(r,"kind").equals("address"))){var copy=new LinkedHashMap<>(s.address());copy.remove("ad_id");copy.remove("ad_modify");copy.putAll(address);company.put("ad_id",db.insert("address",copy));} + else BusinessSupport.update(db,"address","ad_id",id(s.address(),"ad_id"),address); + } + BusinessSupport.update(db,"company","cmp_id",id(s.company(),"cmp_id"),company); + }else if(s.section()==Section.SUPPORT)BusinessSupport.update(db,"headquarters","hq_id",s.target(),new LinkedHashMap<>(values)); + else if(s.section()==Section.HOTLINE||s.target()==0){ + if(s.parameters().isEmpty())db.insert("parameter",fields("par_key",parameter(s.section()),"par_value",values.get("value"),"par_text","","hq_id",s.target(),"md_id",s.access().mandator(),"emp_id",0)); + else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",values.get("value"),id(s.parameters().getFirst(),"par_id")); + }else db.update("UPDATE headquarters SET "+(s.section()==Section.GLN?"hq_gln":"hq_duns")+"=? WHERE hq_id=?",values.get("value"),s.target()); + } + static AccessDeniedException denied(){return new AccessDeniedException("Diese Niederlassungseinstellungen sind nicht zum Lesen oder Ändern freigegeben.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java b/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java new file mode 100644 index 0000000..311c2b2 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalOrders.java @@ -0,0 +1,43 @@ +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 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") +public final class HistoricalOrders { + record Query(int level,LocalDate from,LocalDate to,long headquarters,long customer,long center,boolean descendants,long courier,int status,String search,int page,String basis,String group,HistoricalStatistics.Filters filters){ + Query(int level,LocalDate from,LocalDate to,long headquarters,long customer,long center,boolean descendants,long courier,int status,String search,int page,String basis,String group){this(level,from,to,headquarters,customer,center,descendants,courier,status,search,page,basis,group,null);} + } + record Detail(Query query,long job){} + record Scope(List headquarters,Set centers,List couriers,String payer,boolean customer,boolean courier,boolean statistics,boolean download){} + record Selection(HistoricalTables source,Scope scope,Query query,String where,List args,LegacyUser user){} + final DatabaseService db;final OrderEntryCatalog catalog;final CustomerPortal customers;final CourierPortal couriers; + public HistoricalOrders(DatabaseService db,CustomerPortal customers,CourierPortal couriers){this.db=db;this.catalog=new OrderEntryCatalog(db);this.customers=customers;this.couriers=couriers;} + @GetMapping Map options(Authentication a){return options(ApiController.user(a));} + @PostMapping("/query") Map query(Authentication a,@RequestBody Query q){return query(ApiController.user(a),q);} + @PostMapping("/detail") Map detail(Authentication a,@RequestBody Detail d){return detail(ApiController.user(a),d);} + @PostMapping("/statistics") Map statistics(Authentication a,@RequestBody Query q){return statistics(ApiController.user(a),q);} + Scope scope(LegacyUser u,Query q,boolean statistics){ + if(u.customerAccount()){var c=CustomerPortalScope.context(db,u);c.require(statistics?8:4);if(q==null)return new Scope(List.of(),Set.of(),List.of(),customers.payer(u),true,false,c.can(8),c.can(7));var allowed=CustomerPortalScope.centers(c,q.customer(),q.center(),q.descendants());if(q.courier()!=0)throw CustomerPortalScope.denied();return new Scope(branches(u,q.headquarters(),true),allowed,List.of(),customers.payer(u),true,false,c.can(8),c.can(7));} + if(u.courierAccount()){if(statistics)throw CourierPortal.denied();var s=couriers.scope(u);if(q!=null&&(q.customer()!=0||q.center()!=0||q.courier()!=0&&!s.couriers().contains(q.courier())))throw CourierPortal.denied();return new Scope(branches(u,q==null?0:q.headquarters(),true),Set.of(),q!=null&&q.courier()>0?List.of(q.courier()):s.couriers(),"jb.csc_id_payer",false,true,false,true);} + u.require(statistics?8:4);var hqs=branches(u,q==null?0:q.headquarters(),false);Set centers=Set.of();if(q!=null&&q.customer()>0){var c=db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN ("+CourierPortal.placeholders(hqs.size())+")",args(q.customer(),hqs));if(c.size()!=1)throw bad("Kunde gehört nicht zu den freigegebenen Niederlassungen.");centers=new HashSet<>(db.queryForList("SELECT csc_id FROM costcenter WHERE cs_id=?",q.customer()).stream().map(r->id(r,"csc_id")).toList());if(centers.isEmpty())throw bad("Dem Kunden fehlen Kostenstellen.");}if(q!=null&&q.center()>0){if(!centers.contains(q.center()))throw bad("Kostenstelle gehört nicht zum ausgewählten Kunden.");centers=Set.of(q.center());}if(q!=null&&q.courier()>0&&db.queryForList("SELECT cr_id FROM courier WHERE cr_id=? AND hq_id IN ("+CourierPortal.placeholders(hqs.size())+")",args(q.courier(),hqs)).size()!=1)throw bad("Transporteur gehört nicht zu den freigegebenen Niederlassungen.");return new Scope(hqs,centers,q!=null&&q.courier()>0?List.of(q.courier()):List.of(),catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")?"jb.csc_id_related":"jb.csc_id_payer",false,false,u.hasRight(8),u.hasRight(5)); + } + static Object[] args(Object first,Collection rest){var out=new ArrayList();out.add(first);out.addAll(rest);return out.toArray();} + List branches(LegacyUser u,long selected,boolean portal){var hqs=portal||u.hasRight(10)?db.queryForList("SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=? ORDER BY hq_id",mandator(db,u)).stream().map(r->id(r,"hq_id")).toList():List.of(u.headquartersId());if(selected<0||selected>0&&!hqs.contains(selected))throw WorkspaceHeadquarters.denied();return selected>0?List.of(selected):hqs;} + Map options(LegacyUser u){return db.readTransaction(()->{var s=scope(u,null,u.accountType()==1&&!u.hasRight(4)||u.customerAccount()&&!u.customerRight(4));var local=HistoricalTables.names(db,false);Set remote;String warning="";try{remote=HistoricalTables.names(db,true);}catch(RuntimeException e){remote=Set.of();warning="Der externe Archivserver ist nicht verfügbar. Lokal vorhandene Archivstufen bleiben nutzbar.";}var levels=new ArrayList>();for(int n=1;n<=3;n++)if(local.contains(HistoricalTables.table("job",n))&&local.contains(HistoricalTables.table("tour",n))||remote.contains(HistoricalTables.table("job",n))&&remote.contains(HistoricalTables.table("tour",n)))levels.add(fields("id",n,"label","Archivstufe "+n));var hqs=branches(u,0,u.customerAccount()||u.courierAccount());if(u.accountType()==1&&u.hasRight(10))hqs=db.queryForList("SELECT hq_id FROM mandatorheadquarters GROUP BY hq_id HAVING COUNT(*)=1 AND MAX(md_id)=? ORDER BY hq_id",mandator(db,u)).stream().map(r->id(r,"hq_id")).toList();var customers=u.customerAccount()?CustomerPortalScope.context(db,u).customers().values().stream().map(c->fields("id",c.id(),"label",c.name(),"base",c.base(),"centers",c.centers())).toList():List.of();return fields("levels",levels,"warning",warning,"headquarters",hqs,"currentHeadquarters",u.customerAccount()||u.courierAccount()?0:u.headquartersId(),"customerAccount",s.customer(),"courierAccount",s.courier(),"statistics",s.statistics(),"download",s.download(),"customers",customers,"grossStatistics",!u.customerAccount()&&u.hasRight(23)&&catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"),"installationPrices",catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"),"groupSelection",u.customerAccount()||u.courierAccount()?Map.of():StatisticsGroups.options(db,catalog,u,hqs),"services",StatisticsJobFeatures.enabled(catalog)?StatisticsJobFeatures.options(db):List.of(),"year2",catalog.parameter(0,"STATISTIC_YEAR_OF_BEGIN_HISTORY_2"),"year3",catalog.parameter(0,"STATISTIC_YEAR_OF_BEGIN_HISTORY_3"));});} + static void validate(Query q){if(q==null||q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to())||q.page()<0||q.page()>10000||q.search()==null||q.search().length()>200||!Set.of(-1,0,1,2,8,9,10).contains(q.status())||q.basis()==null||!Set.of("ORDERED","FINISHED","BOOKED","CREATED","EXPORTED").contains(q.basis())||q.customer()<0||q.center()<0||q.courier()<0)throw bad("Bitte gültige Archivfilter und einen Zeitraum von höchstens einem Jahr wählen.");} + Selection select(LegacyUser u,Query q,boolean statistic){validate(q);var s=scope(u,q,statistic);var master=statistic?HistoricalStatisticsMasters.selection(q):null;if(master!=null&&master.relatedCustomer()!=null&&!s.customer())s=new Scope(s.headquarters(),s.centers(),s.couriers(),master.relatedCustomer()?"jb.csc_id_related":"jb.csc_id_payer",s.customer(),s.courier(),s.statistics(),s.download());if((s.customer()||s.courier())&&q.status()!=2)throw bad("Im Portal sind nur erledigte historische Aufträge verfügbar.");var source=HistoricalTables.resolve(db,HistoricalTables.level(q.level(),q.from().getYear(),catalog));var args=new ArrayList(s.headquarters());StringBuilder where=new StringBuilder(" FROM "+source.job()+" jb WHERE jb.hq_id IN ("+CourierPortal.placeholders(s.headquarters().size())+")");if(!s.centers().isEmpty()){where.append(" AND (").append(s.payer()).append(") IN (").append(CourierPortal.placeholders(s.centers().size())).append(')');args.addAll(s.centers());}if(!s.couriers().isEmpty()){where.append(statistic&&HistoricalStatistics.actions(q)?" AND actionEvent.cr_id IN (":" AND jb.cr_id IN (").append(CourierPortal.placeholders(s.couriers().size())).append(')');args.addAll(s.couriers());}if(q.status()!=-1){where.append(" AND jb.jb_status=?");args.add(q.status());}if(s.customer()||s.courier())where.append(" AND COALESCE(jb.jb_offer,0)=0");if(s.customer()&&!catalog.enabled(u.headquartersId(),"CUSTOMER_MASK_JOBLIST_DISPLAY_CANCELLED_JOBS"))where.append(" AND COALESCE(jb.jb_storno,0)=0");String date=s.courier()?"jb_finishtime":switch(q.basis()){case "FINISHED"->"jb_finishtime";case "BOOKED"->"jb_booktime";case "CREATED"->"jb_createtime";case "EXPORTED"->"jb_export_time";default->"jb_ordertime";};if(!statistic||!HistoricalStatistics.actions(q)){where.append(" AND jb.").append(date).append(">=? AND jb.").append(date).append(" row(Map r,Scope s){return fields("id",id(r,"jb_id"),"headquarters",id(r,"hq_id"),"center",s.courier()?0:id(r,"csc_id_payer"),"courier",s.customer()?"":text(r,"cr_sid"),"ordered",dateTime(r,"jb_ordertime"),"finished",dateTime(r,"jb_finishtime"),"booked",dateTime(r,"jb_booktime"),"status",id(r,"jb_status"),"cancelled",id(r,"jb_storno"),"name",text(r,"jb_tourname"),"net",s.courier()?null:decimal(r,"jb_totalprice"),"courierNet",s.customer()?null:decimal(r,id(r,"hq_id")>100&&id(r,"hq_id")<=1000?"jb_cr_subprice":"jb_cr_price"),"incomplete",id(r,"jb_incomplete")!=0);} + Map query(LegacyUser u,Query q){return db.readTransaction(()->{var s=select(u,q,false);var args=new ArrayList(s.args());args.add(101);args.add(q.page()*100);var rows=s.source().read("SELECT "+PROJECTION+s.where()+" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT ? OFFSET ?",args.toArray());return fields("rows",rows.stream().limit(100).map(r->row(r,s.scope())).toList(),"hasNext",rows.size()>100,"page",q.page(),"source",s.source().label(),"level",s.source().level,"download",s.scope().download(),"historical",true);});} + static String address(Map raw,long sort,String field){String prefix="tr_"+sort+"_";String direct=raw.getOrDefault(prefix+field,"");if(!direct.isBlank())return direct;return switch(field){case "ad_street"->raw.getOrDefault(prefix+"ad_street_hsno","");case "ad_city"->raw.getOrDefault(prefix+"ad_zipcode_city","");default->"";};} + static String houseNumber(Map raw,Map stop){String prefix="tr_"+id(stop,"tr_sort")+"_";return raw.getOrDefault(prefix+"ad_street","").isBlank()&&!raw.getOrDefault(prefix+"ad_street_hsno","").isBlank()?"":text(stop,"tr_hsno");} + Map snapshot(Selection selection,Map job){ + var source=selection.source();var time=dateTime(job,"jb_ordertime");if(time==null)return Map.of();String table="job2_"+time.getYear(); + var available=source.read("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?, 'job2') ORDER BY TABLE_NAME DESC",table); + for(var found:available){String name=text(found,"name");if(!name.equals(table)&&!name.equals("job2"))continue;var rows=source.read("SELECT jb2_history_rawdata FROM `"+name+"` WHERE jb_id=? AND OCTET_LENGTH(jb2_history_rawdata)<=? LIMIT 2",id(job,"jb_id"),OrderArchive.MAX_COMPRESSED);if(rows.isEmpty())continue;if(rows.size()!=1)throw bad("Mehrdeutiger historischer Adresssnapshot.");if(rows.getFirst().get("jb2_history_rawdata")==null)continue;var raw=OrderArchive.pairs(OrderArchive.inflate(rows.getFirst().get("jb2_history_rawdata")));if(!Long.toString(id(job,"jb_id")).equals(raw.get("jb_jb_id")))throw bad("Der historische Adresssnapshot gehört zu einem anderen Auftrag.");if(raw.containsKey("jb_hq_id")&&!Long.toString(id(job,"hq_id")).equals(raw.get("jb_hq_id")))throw bad("Die Niederlassung im historischen Adresssnapshot stimmt nicht.");return raw;} + return Map.of(); + } + Map detail(LegacyUser u,Detail d){if(d==null||d.job()<=0)throw bad("Archivauftrag fehlt.");return db.readTransaction(()->{var s=select(u,d.query(),false);var args=new ArrayList(s.args());args.add(d.job());var rows=s.source().read("SELECT "+PROJECTION+s.where()+" AND jb.jb_id=? LIMIT 2",args.toArray());if(rows.size()!=1)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"Historischer Auftrag ist nicht für diese Auswahl freigegeben.");var stops=s.source().read("SELECT tr_sort,tr_comp,tr_comp2,tr_hsno,tr_person,tr_phone,tr_remark,tr_commission_no,tr_signname,tr_finishtime,tr_status,ad_id FROM "+s.source().tour()+" WHERE jb_id=? ORDER BY tr_sort LIMIT 1001",d.job());if(stops.size()>1000)throw bad("Zu viele historische Stationen.");var raw=snapshot(s,rows.getFirst());var publicStops=stops.stream().map(t->fields("street",address(raw,id(t,"tr_sort"),"ad_street"),"zip",address(raw,id(t,"tr_sort"),"ad_zipcode"),"city",address(raw,id(t,"tr_sort"),"ad_city"),"country",address(raw,id(t,"tr_sort"),"ad_country"),"sequence",id(t,"tr_sort"),"company",text(t,"tr_comp"),"additional",text(t,"tr_comp2"),"houseNumber",houseNumber(raw,t),"person",text(t,"tr_person"),"phone",text(t,"tr_phone"),"remark",s.scope().customer()?OrderDeliveryData.publicRemark(text(t,"tr_remark")):text(t,"tr_remark"),"commission",text(t,"tr_commission_no"),"recipient",text(t,"tr_signname"),"finished",dateTime(t,"tr_finishtime"),"addressId",id(t,"ad_id"))).toList();var services=s.source().tables.contains(s.source().service())?s.source().read("SELECT tr_sort,trs_srv_name,trs_srvt_name,trs_price,trs_discount FROM "+s.source().service()+" WHERE jb_id=? ORDER BY tr_sort,srv_id,srvt_id LIMIT 1001",d.job()):List.>of();if(services.size()>1000)throw bad("Zu viele historische Leistungen.");return fields("order",row(rows.getFirst(),s.scope()),"stops",publicStops,"services",s.scope().courier()?List.of():services,"source",s.source().label(),"addressNotice",raw.isEmpty()?"Kein historischer Adresssnapshot vorhanden. Heutige Stammdaten werden nicht als historische Adresse ausgegeben.":"Straßenadressen aus dem gespeicherten historischen Auftragssnapshot.");});} + Map statistics(LegacyUser u,Query q){return db.readTransaction(()->{validate(q);HistoricalStatistics.access(u,q,catalog,db);var result=HistoricalStatistics.read(select(u,q,true),couriers.support.historySchema);var extra=HistoricalStatistics.extra(q);var periods=new ArrayList>();if(extra!=null&&extra.comparisons()!=null){if(extra.comparisons().size()>5)throw bad("Höchstens fünf Vergleichszeiträume sind möglich.");for(var period:extra.comparisons()){if(period==null)throw bad("Vergleichszeitraum fehlt.");var compared=new Query(q.level(),period.from(),period.to(),q.headquarters(),q.customer(),q.center(),q.descendants(),q.courier(),q.status(),q.search(),0,q.basis(),q.group(),q.filters());periods.add(fields("from",period.from(),"to",period.to(),"result",HistoricalStatistics.read(select(u,compared,true),couriers.support.historySchema)));}}result.put("comparisons",periods);return result;});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java new file mode 100644 index 0000000..30bcc0e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatistics.java @@ -0,0 +1,72 @@ +package de.stadtbote.votian; +import java.time.*;import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad;import static de.stadtbote.votian.OrderEntryService.fields; + +/** Only recorded archive columns enter these aggregates; absent prices/logs are never replaced with current data. */ +final class HistoricalStatistics { + record Advanced(StatisticsPrices.Selection prices,List splits,boolean descending,boolean exportedOnly,List requirements,List services,StatisticsService.Creator creator,HistoricalStatisticsMasters.Selection masters,StatisticsGroups.Selection groups,Boolean serviceJob,Boolean atCarrierExpense) { + Advanced(StatisticsPrices.Selection prices,List splits,boolean descending,boolean exportedOnly,List requirements,List services,StatisticsService.Creator creator,HistoricalStatisticsMasters.Selection masters){this(prices,splits,descending,exportedOnly,requirements,services,creator,masters,null,null,null);} + Advanced(StatisticsPrices.Selection prices,List splits,boolean descending,boolean exportedOnly,List requirements,List services,StatisticsService.Creator creator){this(prices,splits,descending,exportedOnly,requirements,services,creator,null);} + } + record Extra(StatisticsCourierActions.Selection action,boolean pickup,Boolean retrospective,StatisticsService.Bounds weight,StatisticsService.Bounds length,StatisticsService.Bounds width,StatisticsService.Bounds height,StatisticsService.Bounds positions,List comparisons,Advanced advanced) { + Extra(StatisticsCourierActions.Selection action,boolean pickup,Boolean retrospective,StatisticsService.Bounds weight,StatisticsService.Bounds length,StatisticsService.Bounds width,StatisticsService.Bounds height,StatisticsService.Bounds positions,List comparisons){this(action,pickup,retrospective,weight,length,width,height,positions,comparisons,null);} + } + static Advanced advanced(HistoricalOrders.Query q){return extra(q)==null?null:extra(q).advanced();} + static void access(LegacyUser u,HistoricalOrders.Query q,OrderEntryCatalog catalog,de.stadtbote.votian.database.DatabaseService db){HistoricalStatisticsMasters.validate(u,q,catalog);var a=advanced(q);if(a==null)return;var prices=a.prices();if(prices!=null){if((q.filters()!=null&&q.filters().calculation()!=null||extra(q).pickup())&&(HistoricalStatisticsMetrics.formula(prices)!=StatisticsPrices.Formula.FULL||prices.gross()||HistoricalStatisticsMetrics.installation(prices)!=StatisticsPrices.InstallationParts.ALL))throw bad("Preisvarianten gelten nur für Auftragswerte.");if(prices.gross()){u.require(23);if(u.customerAccount()||!catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))throw bad("Die Bruttostatistik ist nicht freigegeben.");}if(HistoricalStatisticsMetrics.installation(prices)!=StatisticsPrices.InstallationParts.ALL&&!catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))throw bad("Zusatzmontagebeträge sind nicht freigegeben.");} + if(a.splits()!=null&&(a.splits().size()>5||a.splits().stream().anyMatch(Objects::isNull)||a.splits().stream().distinct().count()!=a.splits().size()||a.splits().contains(q.group())))throw bad("Bitte höchstens fünf unterschiedliche zusätzliche Aufteilungen wählen."); + if(a.requirements()!=null&&(a.requirements().size()>100||a.requirements().stream().anyMatch(v->v==null||v.isBlank()||v.length()>50||!v.equals(v.strip())||v.contains(",")||v.codePoints().anyMatch(Character::isISOControl))||a.requirements().stream().distinct().count()!=a.requirements().size()))throw bad("Ungültige Auftragsanforderung."); + var features=new StatisticsJobFeatures.Selection(a.services(),a.serviceJob(),a.atCarrierExpense());StatisticsGroups.validate(a.groups());if(u.customerAccount()&&StatisticsGroups.selected(a.groups()))throw CustomerPortalScope.denied();StatisticsJobFeatures.validate(features);StatisticsJobFeatures.checkAccess(db,catalog,features); + } + record Filters(StatisticsService.Amount amount,StatisticsService.Payment payment,Boolean permanent,Boolean longhaul,boolean excludeFreeCancellations,LocalTime timeFrom,LocalTime timeTo,Integer vehicleType,Integer actualVehicleType,Integer orderType,String vehicleNumber,StatisticsCalculation.Selection calculation,Extra extra) { + Filters(StatisticsService.Amount amount,StatisticsService.Payment payment,Boolean permanent,Boolean longhaul,boolean excludeFreeCancellations,LocalTime timeFrom,LocalTime timeTo,Integer vehicleType,Integer actualVehicleType,Integer orderType,String vehicleNumber,StatisticsCalculation.Selection calculation){this(amount,payment,permanent,longhaul,excludeFreeCancellations,timeFrom,timeTo,vehicleType,actualVehicleType,orderType,vehicleNumber,calculation,null);} + } + static Extra extra(HistoricalOrders.Query q){return q.filters()==null?null:q.filters().extra();} + static boolean actions(HistoricalOrders.Query q){return extra(q)!=null&&extra(q).action()!=null;} + static String log(HistoricalTables source,String schema){if(!schema.matches("`[A-Za-z0-9_]+`"))throw bad("Ungültiges Archivprotokollschema.");String name="loghistory"+(source.level==1?"":source.level);if(source.read("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?",schema.substring(1,schema.length()-1),name).size()!=1)throw bad("Für diese Archivstufe fehlt das historische Aktionsprotokoll.");return schema+"."+name;} + static String bounds(StatisticsService.Bounds b,String column,List args){if(b==null)return "";String sql="";for(var value:Arrays.asList(b.from(),b.to()))if(value!=null&&(value.signum()<0||value.scale()>6||value.compareTo(java.math.BigDecimal.valueOf(Integer.MAX_VALUE))>0))throw bad("Ungültige Gewichts-, Maß- oder Stellplatzgrenze.");if(b.from()!=null&&b.to()!=null&&b.from().signum()>0&&b.to().signum()>0&&b.from().compareTo(b.to())>0)throw bad("Die Untergrenze darf nicht über der Obergrenze liegen.");if(b.from()!=null&&b.from().signum()>0){sql+=" AND "+column+">=?";args.add(b.from());}if(b.to()!=null&&b.to().signum()>0){sql+=" AND "+column+"<=?";args.add(b.to());}return sql;} + + static String pickup(String expression,HistoricalTables source){return expression.replace("j.","jb.").replace("pickup.stops","(SELECT COUNT(*) FROM "+source.tour()+" pickupCount WHERE pickupCount.jb_id=jb.jb_id AND pickupCount.tr_sort=1)").replace("pickup.finished","(SELECT MAX(pickupTime.tr_finishtime) FROM "+source.tour()+" pickupTime WHERE pickupTime.jb_id=jb.jb_id AND pickupTime.tr_sort=1)");} + static String date(String basis){return "jb."+switch(basis){case "FINISHED"->"jb_finishtime";case "BOOKED"->"jb_booktime";case "CREATED"->"jb_createtime";case "EXPORTED"->"jb_export_time";default->"jb_ordertime";};} + 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);} + 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"; + 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%");}} + if(advanced!=null){if(advanced.exportedOnly())where+=" AND jb.jb_status=2 AND jb.jb_export_time>'0000-00-00 00:00:00' AND jb.jb_export_time<'9999-12-31 23:59:59'"; + if(advanced.requirements()!=null)for(String code:advanced.requirements()){where+=" AND (FIND_IN_SET(?,jb.jb_cr_filter)>0 OR FIND_IN_SET(?,jb.jb_cr_filter_opt)>0)";args.add(code);args.add(code);} + if(advanced.services()!=null&&!advanced.services().isEmpty()){long mask=0;for(int bit:advanced.services())mask|=1L<0";args.add(mask);} + if(advanced.creator()!=null&&advanced.creator()!=StatisticsService.Creator.ALL){var users=s.source().db.queryForList("SELECT usr_id FROM user WHERE usr_type=? ORDER BY usr_id LIMIT 20001",advanced.creator()==StatisticsService.Creator.HEADQUARTERS?1:2).stream().map(r->LegacyMapping.id(r,"usr_id")).toList();if(users.size()>20000)throw bad("Zu viele Ersteller für diese Auswahl.");if(users.isEmpty())where+=" AND 1=0";else{where+=" AND jb.emp_id IN ("+CrmScope.marks(users.size())+")";args.addAll(users);}} + } + String price=calc?"calc.jbc_totalprice":"jb.jb_totalprice"; + if(f!=null){if(f.amount()==StatisticsService.Amount.ORDERS)where+=" AND "+price+">0";if(f.amount()==StatisticsService.Amount.CREDITS)where+=" AND "+price+"<0";if(f.payment()==StatisticsService.Payment.CASH)where+=" AND jb.jb_payment=2";if(f.payment()==StatisticsService.Payment.INVOICE)where+=" AND jb.jb_payment IN (0,1)";if(f.permanent()!=null)where+=" AND COALESCE(jb.jb_permanent,0)"+(f.permanent()?">0":"=0");if(f.longhaul()!=null)where+=" AND COALESCE(jb.jb_longhaul,0)="+(f.longhaul()?1:0);if(f.excludeFreeCancellations())where+=" AND COALESCE(jb.jb_storno,0) IN (0,1,3)"; + for(var item:List.of(Map.entry("jb.vht_id",Objects.requireNonNullElse(f.vehicleType(),0)),Map.entry("jb.vht_id_real",Objects.requireNonNullElse(f.actualVehicleType(),0)),Map.entry("jb.jb_type",Objects.requireNonNullElse(f.orderType(),0))))if(item.getValue()>0){where+=" AND "+item.getKey()+"=?";args.add(item.getValue());} + if(f.vehicleNumber()!=null&&!f.vehicleNumber().isBlank()){if(s.scope().customer())throw CustomerPortalScope.denied();where+=" AND jb.cr_sid=?";args.add(f.vehicleNumber().strip());} + if(!action&&f.timeFrom()!=null){where+=" AND (TIME("+at+")>=? "+(f.timeFrom().isAfter(f.timeTo())?"OR":"AND")+" TIME("+at+")<=?)";args.add(f.timeFrom());args.add(f.timeTo().withSecond(59));} + } + where=HistoricalStatisticsAddresses.join(where,s);var masters=HistoricalStatisticsMasters.prepare(s,where,args);where+=masters.where();args.addAll(masters.values());var selectArgs=new ArrayList(); + String group=pickup?pickup(StatisticsPickupTimes.bucket(),s.source()):expression(q.group(),at,s,calc,action,masters,selectArgs);var groups=new ArrayList(List.of("label"));var selected=new ArrayList(List.of(group+" AS label")); + if(advanced!=null&&advanced.splits()!=null)for(String split:advanced.splits()){String name="split"+(groups.size()-1);selected.add(expression(split,at,s,calc,action,masters,selectArgs)+" AS "+name);groups.add(name);} + if(HistoricalStatisticsMasters.selection(q)!=null&&StatisticsCustomerDetails.selected(HistoricalStatisticsMasters.selection(q).details())){selected.add(masters.expression("statisticsCustomerId",selectArgs)+" AS statisticsCustomerId");groups.add("statisticsCustomerId");} + boolean courierDetails=!s.scope().customer()&&StatisticsCourierDetails.selected(q); + 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 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); + } + static String expression(String requested,String at,HistoricalOrders.Selection s,boolean calc,boolean action,HistoricalStatisticsMasters.Plan masters,List args){String mapped=masters.expression(requested,args);if(mapped!=null)return mapped;return action&&requested.equals("COURIER")?"actionEvent.cr_id":group(requested,at,s.scope().payer(),s.scope().customer(),calc);} + static String group(String requested,String at,String payer,boolean customer,boolean calculation){return switch(Objects.toString(requested,"")){ + case "TOTAL"->"'Gesamt'";case "DAY"->"DATE("+at+")";case "WEEK"->"DATE_FORMAT("+at+",'%x-W%v')";case "MONTH"->"DATE_FORMAT("+at+",'%Y-%m')";case "HALF_MONTH"->"CONCAT(DATE_FORMAT("+at+",'%Y-%m'),IF(DAY("+at+")<=15,'-I','-II'))";case "YEAR"->"YEAR("+at+")";case "HALF_HOUR"->"CONCAT(DATE_FORMAT("+at+",'%H:'),IF(MINUTE("+at+")<30,'00','30'))";case "HOUR"->"DATE_FORMAT("+at+",'%H:00')";case "TWO_HOURS"->"CONCAT(LPAD(FLOOR(HOUR("+at+")/2)*2,2,'0'),':00')"; + case "END_POSTAL"->"SUBSTRING_INDEX(SUBSTRING(jb.jb_tourdata,1,INSTR(jb.jb_tourdata,'|')-1),CHAR(59),-1)";case "SERVICE_MASK"->"jb.jb_service"; + case "HEADQUARTERS"->"jb.hq_id";case "CENTER"->payer;case "VEHICLE_TYPE"->"jb.vht_id";case "ACTUAL_VEHICLE_TYPE"->"jb.vht_id_real";case "ORDER_TYPE"->"jb.jb_type"; + case "COURIER","EMPLOYEE"->{if(customer)throw CustomerPortalScope.denied();yield requested.equals("COURIER")?"jb.cr_sid":"jb.emp_id";} + case "PRICE_TYPE","SERVICE_TYPE","UNIT_PRICE"->{if(!calculation)throw bad("Diese Gruppierung benötigt die Auswertungsbasis Kalkulationspositionen.");yield switch(requested){case "PRICE_TYPE"->"calc.srv_name";case "SERVICE_TYPE"->"calc.srvt_name";default->"CAST(calc.jbc_price AS DECIMAL(18,2))";};} + default->throw bad("Ungültige Archivgruppierung.");};} +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsAddresses.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsAddresses.java new file mode 100644 index 0000000..4553be8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsAddresses.java @@ -0,0 +1,20 @@ +package de.stadtbote.votian; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** PHP station splits use archived station IDs and current address master labels. */ +final class HistoricalStatisticsAddresses { + static boolean targets(HistoricalOrders.Query q){return HistoricalStatisticsMasters.requested(q).contains("ALL_TARGET_POSTAL");} + static String join(String where,HistoricalOrders.Selection s){if(!targets(s.query()))return where;return where.replace(" WHERE jb.hq_id IN ("," JOIN "+s.source().tour()+" targetStop ON targetStop.jb_id=jb.jb_id AND targetStop.tr_sort>1 WHERE jb.hq_id IN (");} + static String station(HistoricalOrders.Selection s,int sequence){return "(SELECT CASE WHEN COUNT(*)=1 THEN MAX(station.ad_id) END FROM "+s.source().tour()+" station WHERE station.jb_id=jb.jb_id AND station.tr_sort="+sequence+")";} + static Map prepare(HistoricalOrders.Selection s,String where,List args){var result=new HashMap();var requested=HistoricalStatisticsMasters.requested(s.query()); + for(var pair:Map.of("START_POSTAL",1,"START_ADDRESS",1,"SECOND_ADDRESS",2,"ALL_TARGET_POSTAL",0).entrySet())if(requested.contains(pair.getKey())){ + String column=pair.getValue()==0?"targetStop.ad_id":station(s,pair.getValue());var keys=HistoricalStatisticsMasters.keys(s,column,where,args); + var rows=HistoricalStatisticsMasters.main(s.source().db,"SELECT ad_id,ad_street,ad_zipcode,ad_city FROM address WHERE ad_id IN ($KEYS)",keys,List.of());var labels=new LinkedHashMap(); + for(var row:rows)labels.put(id(row,"ad_id"),pair.getKey().endsWith("POSTAL")?text(row,"ad_zipcode"):text(row,"ad_street")+" · "+text(row,"ad_zipcode")+" "+text(row,"ad_city")); + result.put(pair.getKey(),HistoricalStatisticsMasters.mapping(column,labels,"CONCAT('Adresse fehlt · ',COALESCE("+column+",0))")); + } + if(requested.contains("PARTNER_COMMISSION"))result.put("PARTNER_COMMISSION",new HistoricalStatisticsMasters.Expression("CAST("+HistoricalStatisticsMetrics.auxiliary(14,s.source().tables.contains("jobprice"))+" AS CHAR)",List.of())); + return result; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java new file mode 100644 index 0000000..e6d747a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsGroups.java @@ -0,0 +1,23 @@ +package de.stadtbote.votian; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +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()); + 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>(); + for(String type:List.of("customer","courier","employee","service","servicetype")){if(selection.staticGroups()&&!type.equals("customer"))continue; + String prefix=switch(type){case "customer"->"cs";case "courier"->"cr";case "employee"->"emp";case "service"->"srv";default->"srvt";};String column=type.equals("employee")?"m.usr_id":type.equals("service")||type.equals("servicetype")?"m."+prefix+"_name":"m."+prefix+"_id"; + String from=type+" m"+(type.equals("employee")?" JOIN user u ON u.usr_id=m.usr_id":"");var args=new ArrayList(hqs);for(long id:selection.ids())args.add("%,"+id+",%"); + var rows=db.queryForList("SELECT DISTINCT "+column+" AS member FROM "+from+" WHERE "+(type.equals("employee")?"u":"m")+".hq_id IN ("+CrmScope.marks(hqs.size())+") AND ("+String.join(" OR ",Collections.nCopies(selection.ids().size(),"m."+prefix+"_group LIKE ?"))+") LIMIT 20001",args.toArray()); + if(rows.size()>20000)throw bad("Mehr als 20000 Gruppenmitglieder. Bitte weniger Gruppen wählen.");if(rows.isEmpty())continue; + if((type.equals("service")||type.equals("servicetype"))&&(s.query().filters()==null||s.query().filters().calculation()==null))throw bad("Die Gruppen enthalten Preis- oder Leistungstypen. Bitte Kalkulationspositionen wählen.");members.put(type,rows.stream().map(r->r.get("member")).toList()); + } + if(members.isEmpty())members.put("customer",List.of());return new Plan(members,selection.customerChildren(),List.of("Gruppen: "+String.join(", ",choices.stream().filter(r->selection.ids().contains(id(r,"id"))).map(r->text(r,"label")).toList()),"Innerhalb einer Mitgliederart gilt ODER, zwischen belegten Mitgliederarten UND.")); + } + static String restrict(String column,List members,List args){if(members.isEmpty())return " AND 1=0";args.addAll(members);return " AND "+column+" IN ("+CrmScope.marks(members.size())+")";} +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java new file mode 100644 index 0000000..f59b1ae --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMasters.java @@ -0,0 +1,73 @@ +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; + +/** Bounded bridge between the archive connection and current, scoped master records. */ +final class HistoricalStatisticsMasters { + record Selection(String customerNumber,String courierNumber,boolean includeOldCustomers,boolean customerChildren,boolean courierChildren, + StatisticsRelationships.CourierGrouping courierGrouping,StatisticsCustomerCohort.Selection cohort, + StatisticsCustomerDetails.Selection details,Boolean relatedCustomer) {} + record Expression(String sql,List values) {} + record Plan(String where,List values,Map expressions,List notices) { + String expression(String key,List values){var e=expressions.get(key);if(e==null)return null;values.addAll(e.values());return e.sql();} + } + static Selection selection(HistoricalOrders.Query q){var a=HistoricalStatistics.advanced(q);return a==null?null:a.masters();} + static Set requested(HistoricalOrders.Query q){var result=new LinkedHashSet();result.add(q.group());var a=HistoricalStatistics.advanced(q);if(a!=null&&a.splits()!=null)result.addAll(a.splits());return result;} + static void validate(LegacyUser u,HistoricalOrders.Query q,OrderEntryCatalog catalog){var m=selection(q);if(u.customerAccount()&&requested(q).stream().anyMatch(Set.of("COURIER_NAME","PARENT_CUSTOMER","PARTNER_COMMISSION")::contains))throw CustomerPortalScope.denied();if(m==null)return; + for(String value:Arrays.asList(m.customerNumber(),m.courierNumber()))if(value!=null&&(value.length()>50||value.codePoints().anyMatch(Character::isISOControl)))throw bad("Ungültige Kunden- oder Transporteurnummer."); + if(m.customerChildren()&&blank(m.customerNumber())||m.courierChildren()&&blank(m.courierNumber()))throw bad("Für zugeordnete Kunden oder Fahrer bitte die Nummer des Gruppenkunden bzw. Unternehmers angeben."); + if(u.customerAccount()&&(m.includeOldCustomers()||m.customerChildren()||m.courierChildren()||!blank(m.customerNumber())||!blank(m.courierNumber())||m.relatedCustomer()!=null||m.courierGrouping()!=null&&m.courierGrouping()!=StatisticsRelationships.CourierGrouping.INDIVIDUAL||StatisticsCustomerDetails.selected(m.details())))throw CustomerPortalScope.denied(); + StatisticsCustomerCohort.validate(m.cohort());StatisticsCustomerCohort.checkAccess(catalog,u,m.cohort()); + if(Boolean.TRUE.equals(m.relatedCustomer())&&!catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))throw bad("Die Auswertung nach zugeordnetem Kunden ist deaktiviert."); + if(StatisticsCustomerDetails.selected(m.details())&&!requested(q).contains("CUSTOMER"))throw bad("Kunden-Zusatzangaben benötigen die Aufteilung nach Kunde."); + } + static boolean blank(String v){return v==null||v.isBlank();} + static List keys(HistoricalOrders.Selection s,String column,String where,List values){var rows=s.source().read("SELECT DISTINCT "+column+" AS archiveKey"+where+" ORDER BY archiveKey LIMIT 20001",values.toArray());if(rows.size()>20000)throw bad("Mehr als 20000 Stammdatenzuordnungen. Bitte Zeitraum oder Niederlassung eingrenzen.");return rows.stream().map(r->id(r,"archiveKey")).filter(v->v>0).toList();} + static List> main(DatabaseService db,String sql,List keys,List tail){var rows=new ArrayList>();for(int n=0;n(part);values.addAll(tail);rows.addAll(db.queryForList(sql.replace("$KEYS",CrmScope.marks(part.size())),values.toArray()));}return rows;} + static Expression mapping(String column,Map values,String fallback){if(values.isEmpty())return new Expression(fallback,List.of());var sql=new StringBuilder("CASE "+column);var args=new ArrayList();for(var e:new TreeMap<>(values).entrySet()){sql.append(" WHEN ? THEN ?");args.add(e.getKey());args.add(e.getValue());}return new Expression(sql+" ELSE "+fallback+" END",args);} + static String restrict(String column,Collection keys,List args){if(keys.isEmpty())return " AND 1=0";args.addAll(keys);return " AND "+column+" IN ("+CrmScope.marks(keys.size())+")";} + static String label(Map row,String id,String number,String name){return String.join(" · ",List.of(text(row,id),text(row,number),text(row,name)).stream().filter(v->!v.isBlank()).toList());} + static Plan prepare(HistoricalOrders.Selection s,String where,List values){ + var q=s.query();var m=selection(q);var requested=requested(q);var expressions=new HashMap();var notices=new ArrayList();var filterArgs=new ArrayList();String filters="";var db=s.source().db;String payer=s.scope().payer();var hqs=s.scope().headquarters();String scope=CrmScope.marks(hqs.size()); + var excluded=HistoricalStatistics.actions(q)||HistoricalStatistics.extra(q)!=null&&HistoricalStatistics.extra(q).pickup()?List.of():StatisticsExclusions.configured(new OrderEntryCatalog(db)); + var groupPlan=HistoricalStatisticsGroups.resolve(s);notices.addAll(groupPlan.notices());var customerMembers=groupPlan.members().get("customer"); + boolean customerFilter=m!=null&&(!blank(m.customerNumber())||m.cohort()!=null&&(m.cohort().createdSince()!=null||m.cohort().customerSince()!=null)); + boolean customer=customerMembers!=null||!excluded.isEmpty()||customerFilter||requested.stream().anyMatch(Set.of("CUSTOMER","PARENT_CUSTOMER","INDUSTRY","CENTER_NAME")::contains)||m!=null&&StatisticsCustomerDetails.selected(m.details()); + if(customer){var keys=keys(s,payer,where,values);var queryArgs=new ArrayList();String change="",join="cs.cs_id=cc.cs_id"; + if(m!=null&&m.includeOldCustomers()){ + change=" LEFT JOIN customer originalCustomer ON originalCustomer.cs_id=cc.cs_id LEFT JOIN (SELECT cs_id_old,COUNT(DISTINCT cs_id) AS targets,MAX(cs_id) AS target FROM customerchanged GROUP BY cs_id_old) oldChange ON oldChange.cs_id_old=cc.cs_id LEFT JOIN customer mapped ON mapped.cs_id=oldChange.target AND oldChange.targets=1 AND mapped.hq_id IN ("+scope+")"; + queryArgs.addAll(hqs);join="cs.cs_id=COALESCE(mapped.cs_id,originalCustomer.cs_id)"; + notices.add("Alte Kundennummern werden um genau eine Zuordnung aufgelöst. Mehrdeutige oder nicht freigegebene Ziele behalten den Ursprungskunden."); + } + String from=" FROM costcenter cc"+change+" JOIN customer cs ON "+join+" LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN branch br ON br.br_id=cmp.br_id LEFT JOIN customer parentCs ON parentCs.cs_id=cs.cs_id_parent AND parentCs.cs_id<>cs.cs_id AND parentCs.hq_id IN ("+scope+") LEFT JOIN company parentCmp ON parentCmp.cmp_id=parentCs.cmp_id"; + 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(!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>(); + String projection="SELECT cc.csc_id,cc.csc_name,cs.cs_id,cs.cs_eid,cmp.cmp_comp,parentCs.cs_id AS parent_id,parentCs.cs_eid AS parent_number,parentCmp.cmp_comp AS parent_name,CONCAT_WS(' ',br.br_key,br.br_name) AS industry"; + for(int n=0;n(queryArgs.subList(0,leading));params.addAll(part);params.addAll(queryArgs.subList(leading,queryArgs.size()));rows.addAll(db.queryForList(projection+from+predicate.replace("$KEYS",CrmScope.marks(part.size())),params.toArray()));} + if(customerFilter||customerMembers!=null||!excluded.isEmpty())filters+=restrict(payer,rows.stream().map(r->id(r,"csc_id")).toList(),filterArgs); + for(String key:List.of("CUSTOMER","PARENT_CUSTOMER","INDUSTRY","CENTER_NAME","statisticsCustomerId")){var mapping=new LinkedHashMap();for(var r:rows){Object value=switch(key){case "CUSTOMER"->label(r,"cs_id","cs_eid","cmp_comp");case "PARENT_CUSTOMER"->id(r,"parent_id")>0?label(r,"parent_id","parent_number","parent_name"):"–";case "INDUSTRY"->text(r,"industry");case "CENTER_NAME"->label(r,"csc_id","csc_name","unused");default->id(r,"cs_id");};mapping.put(id(r,"csc_id"),value);}expressions.put(key,mapping(payer,mapping,key.equals("statisticsCustomerId")?"NULL":"CONCAT('Unzugeordnet · ',"+payer+")"));} + notices.add("Kunden-, Branchen- und Adresszusatzangaben verwenden wie PHP die heutigen Stammdaten, nicht den Zustand zum Auftragsdatum."); + } + var mode=m==null||m.courierGrouping()==null?StatisticsRelationships.CourierGrouping.INDIVIDUAL:m.courierGrouping();boolean courierFilter=m!=null&&!blank(m.courierNumber())||mode==StatisticsRelationships.CourierGrouping.DRIVERS_BY_CONTRACTOR; + if(courierFilter||requested.contains("COURIER_NAME")){String column=HistoricalStatistics.actions(q)?"actionEvent.cr_id":"jb.cr_id";var keys=keys(s,column,where,values);var args=new ArrayList(hqs);args.addAll(hqs);String predicate=""; + if(m!=null&&!blank(m.courierNumber())){predicate+=" AND "+(m.courierChildren()?"parentCr":"cr")+".cr_eid=?";args.add(m.courierNumber().strip());}if(mode==StatisticsRelationships.CourierGrouping.DRIVERS_BY_CONTRACTOR)predicate+=" AND cr.cr_id_parent>0"; + var rows=new ArrayList>();String sql="SELECT cr.cr_id,cr.cr_eid,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS name,parentCr.cr_id AS parent_id,parentCr.cr_eid AS parent_number,CONCAT_WS(' ',pu.usr_firstname,pu.usr_name) AS parent_name FROM courier cr LEFT JOIN user u ON u.usr_id=cr.usr_id LEFT JOIN courier parentCr ON parentCr.cr_id=cr.cr_id_parent AND parentCr.cr_id<>cr.cr_id AND parentCr.hq_id IN ("+scope+") LEFT JOIN user pu ON pu.usr_id=parentCr.usr_id WHERE cr.cr_id IN ($KEYS) AND cr.hq_id IN ("+scope+")"+predicate; + for(int n=0;n(args.subList(0,hqs.size()));params.addAll(part);params.addAll(args.subList(hqs.size(),args.size()));rows.addAll(db.queryForList(sql.replace("$KEYS",CrmScope.marks(part.size())),params.toArray()));} + if(courierFilter)filters+=restrict(column,rows.stream().map(r->id(r,"cr_id")).toList(),filterArgs);var mapping=new LinkedHashMap();for(var r:rows)mapping.put(id(r,"cr_id"),mode!=StatisticsRelationships.CourierGrouping.INDIVIDUAL&&id(r,"parent_id")>0?label(r,"parent_id","parent_number","parent_name"):label(r,"cr_id","cr_eid","name"));expressions.put("COURIER_NAME",mapping(column,mapping,"CONCAT('Unzugeordnet · ',"+column+")"));var courierIds=new LinkedHashMap();for(var r:rows)courierIds.put(id(r,"cr_id"),mode!=StatisticsRelationships.CourierGrouping.INDIVIDUAL&&id(r,"parent_id")>0?id(r,"parent_id"):id(r,"cr_id"));expressions.put("statisticsCourierId",mapping(column,courierIds,"NULL"));notices.add("Transporteur- und Unternehmerzuordnungen stammen aus den heutigen freigegebenen Stammdaten."); + } + for(var member:groupPlan.members().entrySet())if(!member.getKey().equals("customer")){String column=switch(member.getKey()){case "courier"->HistoricalStatistics.actions(q)?"actionEvent.cr_id":"jb.cr_id";case "employee"->"jb.emp_id";case "service"->"calc.srv_name";default->"calc.srvt_name";};filters+=HistoricalStatisticsGroups.restrict(column,member.getValue(),filterArgs);} + var a=HistoricalStatistics.advanced(q);if(a!=null&&(a.serviceJob()!=null||a.atCarrierExpense()!=null)){var ids=keys(s,"jb.jb_id",where,values);for(String key:List.of("jb_service_job","jb_no_payment_job")){Boolean selected=key.equals("jb_service_job")?a.serviceJob():a.atCarrierExpense();if(selected==null)continue;var found=main(db,"SELECT DISTINCT gdc_obj_id AS id FROM genericdatacontainer WHERE gdc_obj_id IN ($KEYS) AND gdc_obj_type='jb' AND gdc_gen_fieldname=?",ids,List.of(key)).stream().map(r->id(r,"id")).collect(java.util.stream.Collectors.toSet());filters+=restrict("jb.jb_id",ids.stream().filter(id->found.contains(id)==selected).toList(),filterArgs);}notices.add("Serviceaufträge und Aufträge zu Lasten der Spedition werden wie PHP anhand der vorhandenen Auftragsmerkmale im Hauptsystem gefiltert.");} + var addresses=HistoricalStatisticsAddresses.prepare(s,where,values);expressions.putAll(addresses);if(!addresses.isEmpty())notices.add("Stationsaufteilungen verwenden die archivierten Adress-IDs mit heutigen Adressstammdaten. Fehlende oder mehrdeutige Adressen bleiben gekennzeichnet."); + if(HistoricalStatisticsAddresses.targets(q))notices.add("Wie PHP zählt jede Zielstation; der vollständige Auftragswert geht je Zielstation ein. Aufträge werden pro Ergebnisgruppe einmal gezählt."); + return new Plan(filters,List.copyOf(filterArgs),expressions,notices); + } + @SuppressWarnings("unchecked") static List> decorate(DatabaseService db,List> rows,Selection m){if(m==null||!StatisticsCustomerDetails.selected(m.details()))return rows;return (List>)StatisticsCustomerDetails.decorate(db,List.of(Map.of("rows",rows)),m.details()).getFirst().get("rows");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMetrics.java b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMetrics.java new file mode 100644 index 0000000..4e054bc --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalStatisticsMetrics.java @@ -0,0 +1,22 @@ +package de.stadtbote.votian; + +import java.util.*; +import static de.stadtbote.votian.StatisticsPrices.*; + +/** Historical amounts are not recalculated using today's courier commission. */ +final class HistoricalStatisticsMetrics { + static String value(String name){return "CAST(jb."+name+" AS DECIMAL(18,4))";} + static String factor(Selection p){return p!=null&&p.gross()?"*1.19":"";} + static Formula formula(Selection p){return p==null||p.formula()==null?Formula.FULL:p.formula();} + static InstallationParts installation(Selection p){return p==null||p.installationParts()==null?InstallationParts.ALL:p.installationParts();} + static String fuel(){return "("+value("jb_totalprice")+"-"+value("jb_subtotalprice")+"-"+value("jb_serviceprice")+")";} + static String revenue(Selection p){if(installation(p)==InstallationParts.ONLY)return "("+value("jb_totalprice")+"-"+value("jb_subtotalprice")+")";if(installation(p)==InstallationParts.EXCLUDE)return value("jb_subtotalprice");return switch(formula(p)){case BASE->value("jb_subtotalprice");case FUEL->fuel();default->value("jb_totalprice");};} + static String auxiliary(int type,boolean available){return available?"(SELECT CASE WHEN COUNT(*)=1 THEN MAX(CAST(p.jbprc_price AS DECIMAL(18,4))) END FROM jobprice p WHERE p.jb_id=jb.jb_id AND p.mt_sort="+type+")":"NULL";} + static String courier(Selection p,boolean auxiliary){String total=value("jb_cr_price"),base=value("jb_cr_subprice"),ten=auxiliary(10,auxiliary);String chosen=switch(formula(p)){case BASE->"COALESCE(NULLIF("+ten+",0),NULLIF("+base+",0))";case FUEL->"CASE WHEN "+ten+" IS NOT NULL AND "+total+" IS NOT NULL AND ("+total+"-"+ten+")<>0 THEN "+total+"-"+ten+" WHEN "+base+"<>0 AND "+total+" IS NOT NULL THEN "+total+"-"+base+" END";default->"NULLIF("+total+",0)";};return (auxiliary&&(formula(p)==Formula.BASE||formula(p)==Formula.FUEL)?"CASE WHEN (SELECT COUNT(*) FROM jobprice p WHERE p.jb_id=jb.jb_id AND p.mt_sort=10)>1 THEN NULL ELSE ":"")+"COALESCE(("+chosen+"),CASE WHEN "+value(formula(p)==Formula.BASE?"jb_subtotalprice":"jb_totalprice")+"=0 THEN 0 END)"+(auxiliary&&(formula(p)==Formula.BASE||formula(p)==Formula.FUEL)?" END":"");} + static String sum(String expression,String alias,Selection p){return "CASE WHEN COUNT("+expression+")=COUNT(*) THEN SUM("+expression+")"+factor(p)+" END AS "+alias;} + static List columns(Selection p,boolean customer,boolean auxiliary){var out=new ArrayList();String revenue=revenue(p);out.add(sum(revenue,"net",p));out.add(sum(value("jb_serviceprice"),"serviceNet",p));out.add(sum(fuel(),"fuelNet",p)); + for(var measure:Map.of("kilometers",11,"co2eKg",12).entrySet()){String v=auxiliary(measure.getValue(),auxiliary);out.add(sum(v,measure.getKey(),null));out.add("COUNT("+v+") AS "+measure.getKey()+"Values");} + if(!customer){String courier=courier(p,auxiliary);out.add(sum(courier,"courierNet",p));out.add(sum(courier(new Selection(Formula.FUEL,false),auxiliary),"courierFuelNet",p));out.add("(COUNT(*)-COUNT("+courier+")) AS missingCourierValues");out.add(sum("("+revenue+"-("+courier+"))","eau",p));out.add("CASE WHEN COUNT("+courier+")=COUNT(*) AND COUNT("+revenue+")=COUNT(*) THEN CASE WHEN SUM("+revenue+")=0 THEN 0 ELSE (SUM("+revenue+")-SUM("+courier+"))/SUM("+revenue+")*100 END END AS bsg");out.add(sum(auxiliary(3,auxiliary),"courierServiceNet",p));} + return out; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/HistoricalTables.java b/backend/src/main/java/de/stadtbote/votian/HistoricalTables.java new file mode 100644 index 0000000..8f3c39f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/HistoricalTables.java @@ -0,0 +1,15 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService;import java.util.*; +import static de.stadtbote.votian.AdminSupport.*;import static de.stadtbote.votian.LegacyMapping.*; +/** PHP getDBNames: three explicit relational history generations, separate from compressed job2 snapshots. */ +final class HistoricalTables { + final DatabaseService db;final boolean remote;final int level;final Set tables; + HistoricalTables(DatabaseService db,boolean remote,int level,Set tables){this.db=db;this.remote=remote;this.level=level;this.tables=tables;} + static int level(int requested,int year,OrderEntryCatalog catalog){if(requested<0||requested>3)throw bad("Ungültige Archivstufe.");if(requested>0)return requested;int level=1;for(int n=2;n<=3;n++){String value=catalog.parameter(0,"STATISTIC_YEAR_OF_BEGIN_HISTORY_"+n);if(!value.isBlank()){int cutoff;try{cutoff=Integer.parseInt(value);}catch(Exception e){throw bad("Die Jahresgrenze des Archivs ist ungültig.");}if(cutoff<1900||cutoff>9998)throw bad("Die Jahresgrenze des Archivs ist ungültig.");if(level==n-1&&year<=cutoff)level=n;}}return level;} + static String table(String base,int level){if(!Set.of("job","tour","tourservice").contains(base)||level<1||level>3)throw bad("Ungültige Archivtabelle.");return base+"history"+(level==1?"":level);} + static Set names(DatabaseService db,boolean remote){String sql="SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME REGEXP '^(jobhistory[23]?|tourhistory[23]?|tourservicehistory[23]?|jobcalculatorhistory|jobpaymenthistory|jobprice)$' ORDER BY TABLE_NAME";return new HashSet<>((remote?db.queryHistoricalForList(sql):db.queryForList(sql)).stream().map(r->text(r,"name")).toList());} + static HistoricalTables resolve(DatabaseService db,int level){var local=names(db,false);if(local.contains(table("job",level))&&local.contains(table("tour",level)))return new HistoricalTables(db,false,level,local);var remote=names(db,true);if(!remote.contains(table("job",level))||!remote.contains(table("tour",level)))throw bad("Archivstufe "+level+" ist nicht vorhanden. Bitte eine vorhandene Stufe auswählen.");return new HistoricalTables(db,true,level,remote);} + List> read(String sql,Object... args){return remote?db.queryHistoricalForList(sql,args):db.queryForList(sql,args);} + String job(){return table("job",level);}String tour(){return table("tour",level);}String service(){return table("tourservice",level);} + String label(){return (remote?"Archivserver":"Bestandsdatenbank")+" · Stufe "+level;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/ImportPostalOrder.java b/backend/src/main/java/de/stadtbote/votian/ImportPostalOrder.java new file mode 100644 index 0000000..dbf59e4 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/ImportPostalOrder.java @@ -0,0 +1,12 @@ +package de.stadtbote.votian; +import java.util.*; + +/** Current PHP XSplanTourStb978440 postal priorities; ties retain file order. */ +final class ImportPostalOrder { + private ImportPostalOrder(){} + static final List> PRIORITIES=List.of( + List.of("13593","14089","13595","13581","13591","13583","13589","13587","13585","13597","14050","14052","14053","14055","14057","14059","13599","13629","13627","13351","10553","10551","10555","10557","10559","13353","13347","13355","13357","13359","13409","13407","13349","13405","13507","13403","13509","13437","13439","13435","13469","13467","13465","13503","13505"), + List.of("13593","14089","13595","13597","13599","13587","13585","13589","13583","13581","13591","14612","16727","13505","13503","13465","13467","13469","13435","13439","13437","13509","13507","13405","13403","13407","13409","13359","13357","13355","13347","13353","13349","13351","10553","10555","10557","10559","10551","13627","13629","14050","14059","14057","14055","14052","14053") + ); + static List order(String customer,List postals){if(!customer.equals("STB978440")||postals.size()<3)return List.of();for(var priorities:PRIORITIES)if(priorities.containsAll(postals)){var order=new ArrayList();for(int i=0;icomparingInt(i->priorities.indexOf(postals.get(i))).thenComparingInt(i->i));return List.copyOf(order);}return List.of();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/InformationDashboard.java b/backend/src/main/java/de/stadtbote/votian/InformationDashboard.java new file mode 100644 index 0000000..b55922e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/InformationDashboard.java @@ -0,0 +1,86 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.time.*; +import java.util.*; +import java.util.function.Supplier; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Start-page information from the current PHP contract. Opening this page never changes user state. */ +@RestController +class InformationDashboard { + static final List NOTICE_KEYS=List.of("global_cs_info","global_cs_email_info"); + final DatabaseService db; final BusinessSupport support; final AppointmentService appointments; + InformationDashboard(DatabaseService db,BusinessSupport support,AppointmentService appointments){this.db=db;this.support=support;this.appointments=appointments;} + record Notices(String requestId,String revision,String customerInfo,String emailInfo){} + @GetMapping("/api/account/info") Object get(Authentication a){return view(ApiController.user(a),LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));} + @GetMapping("/api/account/info/search") Object search(Authentication a,@RequestParam String q){return search(ApiController.user(a),q);} + @PutMapping("/api/account/info/notices") Object save(Authentication a,@RequestBody Notices c){return save(ApiController.user(a),c);} + @GetMapping("/api/customer-portal/info") Object customer(Authentication a){return customer(ApiController.user(a));} + + Map actor(LegacyUser u){ + HeaderActivity.access(u); + return one(db.queryForList("SELECT employee.emp_id,employee.emp_rights,user.usr_id,user.hq_id,user.usr_firstname,user.usr_name FROM employee JOIN user ON user.usr_id=employee.usr_id WHERE employee.emp_id=? AND user.usr_id=? AND user.hq_id=? AND user.usr_type=1 AND employee.csc_id=0",u.employeeId(),u.userId(),u.originalHeadquartersId())); + } + boolean canEdit(LegacyUser u,Map actor){return u.headquartersId()==u.originalHeadquartersId()&&u.hasRight(0)&&u.hasRight(35)&&EmployeeAdministration.bit(text(actor,"emp_rights"),0)&&EmployeeAdministration.bit(text(actor,"emp_rights"),35);} + List> noticeRows(long hq){ + var rows=db.queryForList("SELECT gdc_id,gdc_gen_fieldname,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='hq2cs' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('global_cs_info','global_cs_email_info') ORDER BY gdc_id",hq); + for(String key:NOTICE_KEYS)if(rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(key)).count()>1)throw bad("Die Kundenhinweise sind mehrfach vorhanden. Bitte den Bestand prüfen."); + return rows; + } + static String notice(List> rows,String key){return rows.stream().filter(r->text(r,"gdc_gen_fieldname").equals(key)).findFirst().map(r->text(r,"gdc_content")).orElse("");} + static String display(String value){return value.replace("\\r\\n","\n").replace("\\n\\r","\n").replace("\\n","\n").replace("\\r","\n");} + Map notices(LegacyUser u,Map actor){var rows=noticeRows(u.headquartersId());return fields("canEdit",canEdit(u,actor),"revision",revision(List.of(u.headquartersId(),rows)),"customerInfo",notice(rows,NOTICE_KEYS.get(0)),"emailInfo",notice(rows,NOTICE_KEYS.get(1)));} + Map customer(LegacyUser u){return db.readTransaction(()->{CustomerPortalScope.verify(db,u);var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='hq2cs' AND gdc_obj_id=? AND gdc_gen_fieldname='global_cs_info'",u.headquartersId());if(rows.size()>1)throw bad("Die Kundeninformation ist mehrfach vorhanden.");return fields("text",rows.isEmpty()?"":display(text(rows.getFirst(),"gdc_content")));});} + static void validate(Notices c){if(c==null||c.revision()==null||!c.revision().matches("[a-f0-9]{64}")||c.customerInfo()==null||c.emailInfo()==null||c.customerInfo().length()>16000||c.emailInfo().length()>16000||c.customerInfo().indexOf('\0')>=0||c.emailInfo().indexOf('\0')>=0)throw bad("Die Hinweise dürfen jeweils höchstens 16000 Zeichen enthalten.");BusinessSupport.token(c.requestId());} + Map save(LegacyUser u,Notices c){HeaderActivity.access(u);u.require(0);u.require(35);validate(c);if(u.headquartersId()!=u.originalHeadquartersId())throw new AccessDeniedException("Kundenhinweise können nur in der Heimatniederlassung geändert werden.");support.transactional("genericdatacontainer");return db.employeeActivityWriteTransaction(()->{ + var actor=actor(u);if(!canEdit(u,actor))throw new AccessDeniedException("Die Berechtigung zum Ändern der Kundenhinweise fehlt."); + if(support.receipt(u,c.requestId(),c)!=null)return notices(u,actor); + var rows=noticeRows(u.headquartersId());if(!revision(List.of(u.headquartersId(),rows)).equals(c.revision()))throw bad("Die Kundenhinweise wurden inzwischen geändert. Bitte neu laden."); + for(int i=0;itext(r,"gdc_gen_fieldname").equals(key)).toList(); + if(found.isEmpty()){if(!value.isEmpty())db.insert("genericdatacontainer",fields("gdc_obj_type","hq2cs","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context",""));} + else if(!text(found.getFirst(),"gdc_content").equals(value))db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_id=?",value,id(found.getFirst(),"gdc_id")); + } + support.receipt(u,c.requestId(),c,u.headquartersId());return notices(u,actor); + });} + Map section(Supplier> read){try{var result=new LinkedHashMap<>(read.get());result.put("available",true);return result;}catch(org.springframework.dao.DataAccessException e){return fields("available",false,"message","Dieser Bereich konnte nicht geladen werden. Bitte erneut versuchen.");}} + static Map denied(){return fields("available",false,"message","Für diesen Bereich fehlt die Berechtigung.");} + Map view(LegacyUser u,LocalDateTime now){var actor=actor(u);return fields("name",(text(actor,"usr_firstname")+" "+text(actor,"usr_name")).strip(),"headquarters",u.headquartersId(),"asOf",now, + "birthdays",section(()->db.readTransaction(()->birthdays(u,now.toLocalDate()))),"notices",section(()->db.readTransaction(()->notices(u,actor))), + "reports",u.hasRight(16)&&(u.hasRight(0)||u.hasRight(1))?section(()->db.readTransaction(()->reports(u,now))):denied(), + "appointments",u.hasRight(0)&&u.hasRight(11)?section(()->appointments(u,now.toLocalDate())):denied(),"canSearch",u.hasRight(0)||u.hasRight(1)||u.hasRight(4));} + Map birthdays(LegacyUser u,LocalDate today){ + String scope="(user.usr_type=1"+(u.hasRight(0)?" OR (user.usr_type=2 AND EXISTS (SELECT 1 FROM employee e JOIN costcenter cc ON cc.csc_id=e.csc_id JOIN customer c ON c.cs_id=cc.cs_id JOIN company co ON co.cmp_id=c.cmp_id WHERE e.usr_id=user.usr_id AND c.hq_id=user.hq_id AND co.cmp_visible=1 AND co.cmp_authenticated=1 AND COALESCE(co.cmp_archived,0)=0))":"")+(u.hasRight(1)?" OR (user.usr_type=3 AND EXISTS (SELECT 1 FROM courier c JOIN company co ON co.cmp_id=c.cmp_id WHERE c.usr_id=user.usr_id AND c.hq_id=user.hq_id AND co.cmp_visible=1 AND co.cmp_authenticated=1 AND COALESCE(co.cmp_archived,0)=0))":"")+")"; + var rows=db.queryForList("SELECT user.usr_type AS type,CONCAT_WS(' ',user.usr_firstname,user.usr_name) AS name FROM user WHERE user.hq_id=? AND RIGHT(user.usr_birthdate,5)=? AND "+scope+" ORDER BY user.usr_type,user.usr_name,user.usr_firstname,user.usr_id LIMIT 1001",u.headquartersId(),today.toString().substring(5));return fields("date",today,"rows",rows.stream().limit(1000).toList(),"truncated",rows.size()>1000); + } + static LocalDate previousMonth(LocalDate date){return date.withDayOfMonth(1).minusMonths(1).plusDays(date.getDayOfMonth()-1);} + @SuppressWarnings("unchecked") Map appointments(LegacyUser u,LocalDate today){ + var visibility=List.of(AppointmentService.Visibility.values());var monitored=appointments.list(u,new AppointmentService.Query(previousMonth(today),today.plusDays(7),List.of(u.headquartersId()),u.userId(),null,null,null,1,visibility,"")); + var normal=appointments.list(u,new AppointmentService.Query(today,today.plusDays(1),List.of(u.headquartersId()),u.userId(),null,null,null,0,visibility,"")); + var items=new ArrayList>((List>)monitored.get("items"));items.addAll((List>)normal.get("items")); + return fields("monitoredFrom",previousMonth(today),"monitoredTo",today.plusDays(7),"normalFrom",today,"normalTo",today.plusDays(1),"items",items,"total",items.size()); + } + Map reports(LegacyUser u,LocalDateTime now){var logins=LoginActivity.recent(db,support,u,now.minusDays(7),now);LocalDateTime from=logins.size()>1?logins.get(1):null;var result=fields("previousLogin",from!=null,"from",from,"to",now,"customer",u.hasRight(0),"courier",u.hasRight(1)); + for(String kind:List.of("cs","cr")){if(!(kind.equals("cs")?u.hasRight(0):u.hasRight(1)))continue;if(from==null){result.put(kind,fields("total",0,"rows",List.of()));continue;} + var args=new ArrayList(List.of(u.headquartersId(),kind,from,now));String where="r.hq_id=? AND r.rp_objtype=? AND r.rp_createtime>=? AND r.rp_createtime<=?";if(!u.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(u.userId());} + long total=db.queryForObject("SELECT COUNT(*) FROM "+ReportAdministration.TABLE+" r WHERE "+where,Long.class,args.toArray());var rows=db.queryForList("SELECT r.rp_id AS id,r.rp_createtime AS created,r.rp_text AS text,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS author FROM "+ReportAdministration.TABLE+" r LEFT JOIN user usr ON usr.usr_id=r.usr_id WHERE "+where+" ORDER BY r.rp_createtime DESC,r.rp_id DESC LIMIT 10",args.toArray());result.put(kind,fields("total",total,"rows",rows)); + }return result; + } + static String searchText(String q){if(q==null||q.strip().length()<3||q.strip().length()>100)throw bad("Bitte 3 bis 100 Suchzeichen eingeben.");return q.strip();} + static String pattern(String q){return "%"+q.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";} + Map search(LegacyUser u,String q){HeaderActivity.access(u);String value=searchText(q),like=pattern(value);return db.readTransaction(()->{ + actor(u);var result=new LinkedHashMap(); + if(u.hasRight(4)){var rows=value.matches("[0-9]{1,18}")?db.queryForList("SELECT job.jb_id AS id,0 AS parent,CONCAT('Auftrag ',job.jb_id) AS label,(SELECT MIN(tr_commission_no) FROM tour WHERE tour.jb_id=job.jb_id AND tr_sort=1) AS detail FROM job WHERE job.jb_id=? AND job.hq_id=?",Long.parseLong(value),u.headquartersId()):List.>of();result.put("orders",fields("rows",rows,"truncated",false));} + for(String kind:List.of("customers","couriers","vehicles")){boolean cs=kind.equals("customers"),vehicle=kind.equals("vehicles");if(!u.hasRight(cs?0:1))continue;String prefix=cs?"cs":"cr",table=cs?"customer":"courier"; + String joins=" FROM "+table+" c JOIN company co ON co.cmp_id=c.cmp_id "+(cs?"LEFT JOIN employee e ON e.emp_id=c.cs_admin LEFT JOIN user usr ON usr.usr_id=e.usr_id":"LEFT JOIN user usr ON usr.usr_id=c.usr_id")+(vehicle?" JOIN couriervehicle v ON v.cr_id=c.cr_id":""); + String match=vehicle?"v.crvh_sid LIKE ?":"(c."+prefix+"_eid LIKE ? OR co.cmp_comp LIKE ? OR co.cmp_match LIKE ? OR usr.usr_name LIKE ? OR usr.usr_firstname LIKE ?"+(cs?"":" OR c.cr_sid LIKE ?")+")"; + var args=new ArrayList();args.add(u.headquartersId());for(int n=0;n<(vehicle?1:cs?5:6);n++)args.add(like); + var rows=db.queryForList("SELECT "+(vehicle?"v.crvh_id AS id,c.cr_id AS parent,CONCAT_WS(' · ',v.crvh_sid,c.cr_eid)":"c."+prefix+"_id AS id,0 AS parent,CONCAT_WS(' · ',c."+prefix+"_eid,co.cmp_comp)")+" AS label,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS detail"+joins+" WHERE c.hq_id=? AND co.cmp_visible=1 AND co.cmp_authenticated=1 AND COALESCE(co.cmp_archived,0)=0 AND "+match+" ORDER BY label,id LIMIT 51",args.toArray());result.put(kind,fields("rows",rows.stream().limit(50).toList(),"truncated",rows.size()>50)); + }return result; + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java b/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java new file mode 100644 index 0000000..55810b1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/InvoiceCsvExport.java @@ -0,0 +1,90 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import org.springframework.http.*; +import java.nio.charset.StandardCharsets; +import java.math.*; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** The central CSV menu invokes special_export_04, not the configurable customer export. */ +@RestController +@RequestMapping("/api/billing/customer-csv") +class InvoiceCsvExport { + record Query(long customer,LocalDate from,LocalDate to,String revision,String filename){} + record Save(String requestId,Query query){} + record Snapshot(String revision,CustomerExportData.Scope scope,List> orders,List> rows,List warnings){} + static final List HEADER=List.of("Date","Partner Name","Driver","Street","Zipcode","City","Tax-ID","Order No","Time","Delivery","Courier Flat","ad on km","ad on km EUR","Late","Weekend","Waiting Time","Stopps","Other charges","Comment","Special Business","Total"); + static final List SERVICES=List.of("Mindestpreis","Mehrkilometer","Nachtzuschlag","Wochenendzuschlag","Wartezeitzuschlag","Stopps"); + static final Map PARTNERS=Map.of("HB","Bremen","HH","Hamburg","B","Berlin","H","Hannover","F","Schwalbach","DD","Dresden","E","Essen","L","Leipzig","S","Leonberg","M","München"); + final DatabaseService db;final BusinessSupport support;final ExportFileStore store; + 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);} + 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ß."); + var own=new TreeSet();var group=new TreeSet();var data=new CustomerExportData(db); + if(children.isEmpty())own.addAll(data.centers(customer,id(cs,"csc_id"),id(cs,"csc_id"))); + else for(var child:children)if(id(child,"hq_id")==u.headquartersId())group.addAll(data.centers(id(child,"cs_id"),id(child,"csc_id"),id(child,"csc_id"))); + if(own.size()+group.size()>10000)throw bad("Zu viele Kostenstellen für den CSV-Export."); + // Negative headquarters distinguishes this central, HQ-only scope from portal mandator access. + return new CustomerExportData.Scope(customer,id(cs,"csc_id"),-u.headquartersId(),Set.copyOf(own),Set.copyOf(group)); + } + void check(LegacyUser u,Query q){rights(u);if(q==null||q.customer()<=0)throw bad("Bitte einen Kunden auswählen.");if(q.from()==null||q.to()==null||q.from().getYear()<1900||q.to().getYear()>9998||q.from().isAfter(q.to())||q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");} + Snapshot snapshot(LegacyUser u,Query q){check(u,q);return db.readTransaction(()->{ + var scope=scope(u,q.customer());var centers=new TreeSet<>(scope.allCenters());var warnings=new ArrayList();var rows=new ArrayList>();var orders=new ArrayList>(); + if(!scope.childCenters().isEmpty())warnings.add("Gruppenkunde: Es werden die direkten Unterkunden der eigenen Niederlassung ausgegeben; eigene Aufträge des Gruppenkunden sind wie in der PHP-CSV nicht enthalten."); + if(centers.isEmpty())return new Snapshot(revision(List.of(scope,q.from(),q.to())),scope,orders,rows,List.of("Keine freigegebenen Kostenstellen in dieser Niederlassung.")); + var args=new ArrayList(centers);args.add(u.headquartersId());args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay()); + var jobs=db.queryForList("SELECT j.jb_id,j.jb_ordertime,j.jb_finishtime,j.jb_totalprice,j.jb_freetext_1,j.vht_id,j.cr_sid,j.cr_id,h.hq_mnemonic,h.hq_name,cr.cr_id_parent,cr.cmp_id,cr.usr_id,u.usr_id AS valid_user,u.usr_name,u.usr_firstname,c.cmp_tax_idno,c.cmp_stax_idno,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_id AS valid_address FROM job j JOIN headquarters h ON h.hq_id=j.hq_id LEFT JOIN courier cr ON cr.cr_id=j.cr_id AND cr.hq_id=j.hq_id LEFT JOIN user u ON u.usr_id=cr.usr_id AND u.hq_id=cr.hq_id LEFT JOIN company c ON c.cmp_id=cr.cmp_id LEFT JOIN address a ON a.ad_id=c.ad_id WHERE j.csc_id_payer IN ("+CourierPortal.placeholders(centers.size())+") AND j.hq_id=? AND j.jb_status=2 AND COALESCE(j.jb_storno,0) IN (0,1,3) AND j.jb_totalprice>0 AND j.jb_export_time IS NOT NULL AND j.jb_export_time<>'0000-00-00 00:00:00' AND j.jb_export_time<>'9999-12-31 23:59:59' AND j.jb_finishtime>=? AND j.jb_finishtime2000)throw bad("Mehr als 2.000 CSV-Aufträge. Bitte den Zeitraum eingrenzen."); + var fingerprint=new ArrayList(List.of(CustomerExportStorage.stamp(scope),q.from(),q.to(),jobs));var ids=jobs.stream().map(j->id(j,"jb_id")).toList();if(ids.isEmpty())return new Snapshot(revision(fingerprint),scope,orders,rows,warnings); + var loader=new CustomerExportData(db);String marks=CourierPortal.placeholders(ids.size()); + var stops=loader.bounded("SELECT t.tr_id,t.jb_id,t.tr_sort,t.tr_commission_no,t.tr_finishtime FROM tour t WHERE t.jb_id IN ("+marks+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",ids); + var calc=loader.bounded("SELECT jb_id,tr_sort,srv_name,srvt_name,jbc_amount,jbc_totalprice FROM jobcalculator WHERE jb_id IN ("+marks+") ORDER BY jb_id,tr_sort,srv_name,srvt_name,jbc_amount,jbc_totalprice",ids); + var arrivals=loader.bounded("SELECT t.jb_id,g.gdc_content 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='arr_tour' WHERE t.tr_sort=1 AND t.jb_id IN ("+marks+") ORDER BY t.jb_id,g.gdc_id",ids); + fingerprint.addAll(List.of(stops,calc,arrivals));var byStop=CustomerExportData.group(stops,"jb_id");var byCalc=CustomerExportData.group(calc,"jb_id");var byArrival=CustomerExportData.group(arrivals,"jb_id"); + int maximum=5;String configured=new OrderEntryCatalog(db).parameter(0,"EXPORT_SPECIAL_FUNCTION_MAX_NUM_OF_STATIONS_"+q.customer());if(!configured.isBlank())try{maximum=Integer.parseInt(configured);}catch(NumberFormatException e){throw bad("Die maximale Stationszahl des CSV-Exports ist ungültig.");}if(maximum<1||maximum>100)throw bad("Die maximale Stationszahl des CSV-Exports muss zwischen 1 und 100 liegen.");fingerprint.add(maximum); + for(var job:jobs){long id=id(job,"jb_id");if(id(job,"valid_address")<=0||id(job,"valid_user")<=0)throw bad("Auftrag #"+id+": Kurier, Benutzer oder Firmenadresse fehlen für die CSV-Ausgabe.");var station=byStop.getOrDefault(id,List.of());if(station.stream().map(t->id(t,"tr_sort")).distinct().count()!=station.size())throw bad("Auftrag #"+id+": Stationsfolge ist mehrdeutig.");var arrival=byArrival.getOrDefault(id,List.of());if(arrival.size()>1)throw bad("Auftrag #"+id+": mehrere Ankunftszeiten an Station 1."); + var address=job;if(id(job,"cr_id_parent")>0){address=one(db.queryForList("SELECT a.ad_street,a.ad_zipcode,a.ad_city FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id JOIN address a ON a.ad_id=c.ad_id WHERE cr.cr_id=? AND cr.hq_id=?",id(job,"cr_id_parent"),u.headquartersId()));fingerprint.add(address);} + var values=new ArrayList();values.add(CustomerExportData.date(job,"jb_ordertime","dd.MM.yyyy"));String partner=PARTNERS.get(text(job,"hq_mnemonic"));if(partner==null){partner=text(job,"hq_name");if(!warnings.contains("Unbekanntes Niederlassungskürzel: Der Niederlassungsname wird als Partner verwendet."))warnings.add("Unbekanntes Niederlassungskürzel: Der Niederlassungsname wird als Partner verwendet.");}else partner="Stadtbote "+partner;values.add(partner); + 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))); + 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 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();} + static void expected(Query q,Snapshot s){if(q.revision()==null||!q.revision().equals(s.revision()))throw bad("Die CSV-Daten wurden geändert. Bitte die Vorschau neu laden.");} + Map preview(LegacyUser u,Query q){var s=snapshot(u,q);String content=render(s);if(!snapshot(u,q).revision().equals(s.revision()))throw bad("Die CSV-Daten wurden während der Vorschau geändert. Bitte neu laden.");return fields("revision",s.revision(),"headers",HEADER,"rows",s.rows().stream().limit(100).toList(),"orders",s.orders(),"count",s.rows().size(),"bytes",content.getBytes(StandardCharsets.UTF_8).length,"truncated",s.rows().size()>100,"warnings",s.warnings());} + Map generate(LegacyUser u,Query q){var s=snapshot(u,q);expected(q,s);String content=render(s);if(!snapshot(u,q).revision().equals(s.revision()))throw bad("Die CSV-Daten wurden während der Erstellung geändert. Bitte neu laden.");return fields("filename",CustomerExports.filename(q.filename()),"content",content,"rows",s.rows().size(),"scope",CustomerExportStorage.stamp(s.scope()));} + CustomerExportFiles files(long customer){return new CustomerExportFiles(db,store){@Override Access access(LegacyUser u){scope(u,customer);String path=new OrderEntryCatalog(db).parameter(0,"EXPORT_PATH");if(!path.isBlank()&&!path.replaceAll("/+$","").equals("../export/download"))throw bad("Die konfigurierte PHP-Exportablage muss abgeglichen werden.");return new Access(customer,true);}};} + CustomerExportStorage storage(long customer){return new CustomerExportStorage(db,support,null,files(customer),store){@Override CustomerExportData.Scope scope(LegacyUser u){return InvoiceCsvExport.this.scope(u,customer);}};} + CustomerExportRemoval removal(long customer){return new CustomerExportRemoval(db,support,files(customer),store);} + @GetMapping("/{customer}/files") Object files(Authentication a,@PathVariable long customer,@RequestParam(defaultValue="0")int page){return files(customer).list(ApiController.user(a),page);} + @GetMapping("/{customer}/files/{file}/download") ResponseEntity stored(Authentication a,@PathVariable long customer,@PathVariable long file){var v=files(customer).download(ApiController.user(a),file);return response(text(v,"name"),(byte[])v.get("data"),"application/octet-stream");} + @GetMapping("/{customer}/storage") Object operations(Authentication a,@PathVariable long customer){return storage(customer).operations(ApiController.user(a));} + @PostMapping("/store") Object save(Authentication a,@RequestBody Save s){return save(ApiController.user(a),s);} + Map save(LegacyUser u,Save s){rights(u);if(s==null||s.query()==null)throw bad("Exportauswahl fehlt.");var expectedScope=new String[1];var storage=storage(s.query().customer());return storage.startGenerated(u,s.requestId(),List.of("CENTRAL_CSV_04",u.userId(),u.headquartersId(),s),()->{var data=generate(u,s.query());expectedScope[0]=text(data,"scope");return data;},()->{if(!Objects.equals(expectedScope[0],CustomerExportStorage.stamp(scope(u,s.query().customer()))))throw bad("Der Kundenumfang wurde geändert. Bitte neu laden.");});} + @PostMapping("/{customer}/storage/{requestId}/resume") Object resume(Authentication a,@PathVariable long customer,@PathVariable String requestId){return storage(customer).resume(ApiController.user(a),requestId);} + @PostMapping("/{customer}/storage/{requestId}/cancel") Object cancel(Authentication a,@PathVariable long customer,@PathVariable String requestId){return storage(customer).cancel(ApiController.user(a),requestId);} + @GetMapping("/{customer}/removal") Object removed(Authentication a,@PathVariable long customer){return removal(customer).list(ApiController.user(a));} + @GetMapping("/{customer}/removal/{file}") Object removePreview(Authentication a,@PathVariable long customer,@PathVariable long file){return removal(customer).preview(ApiController.user(a),file);} + @PostMapping("/{customer}/removal") Object remove(Authentication a,@PathVariable long customer,@RequestBody CustomerExportRemoval.Command c){return removal(customer).remove(ApiController.user(a),c);} + @PostMapping("/{customer}/removal/{requestId}/resume") Object resumeRemoval(Authentication a,@PathVariable long customer,@PathVariable String requestId){return removal(customer).resume(ApiController.user(a),requestId);} + @PostMapping("/{customer}/removal/{requestId}/restore") Object restore(Authentication a,@PathVariable long customer,@PathVariable String requestId){return removal(customer).restore(ApiController.user(a),requestId);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java b/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java new file mode 100644 index 0000000..a0ad01c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/InvoiceDocuments.java @@ -0,0 +1,73 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import org.springframework.http.*; +import java.io.*; +import java.math.BigDecimal; +import java.util.*; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.multipdf.PDFMergerUtility; +import org.apache.pdfbox.rendering.PDFRenderer; +import javax.imageio.ImageIO; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.OrderCustomerMailContent.escape; +import static de.stadtbote.votian.OrderCustomerMailContent.money; +import static de.stadtbote.votian.OrderCustomerMailContent.time; + +/** Read-only equivalent of jb_inv_assoc.php's per-invoice order-detail PDF. */ +@RestController +@RequestMapping("/api/billing/invoice-documents") +class InvoiceDocuments { + final BillingService billing;final DatabaseService db; + InvoiceDocuments(BillingService billing){this.billing=billing;this.db=billing.db;} + record Request(long customer,String number,String sort,String revision,int page){} + 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));} + 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())); + var pending=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_obj_id=? AND gdc_context='PENDING' LIMIT 1001",u.headquartersId());if(pending.size()>1000)throw bad("Zu viele offene Rechnungsvorgänge. Bitte diese zuerst prüfen.");for(var p:pending)if(billing.decode(p).number().equalsIgnoreCase(r.number().strip()))throw bad("Für diese Rechnungsnummer läuft noch eine Zuordnung. Bitte den Vorgang zuerst abschließen oder beenden."); + var jobs=db.queryForList("SELECT j.*,i.inv_no,CAST(i.inv_datetime AS CHAR) AS invoice_date,cc.csc_name AS payer_center FROM invoice i JOIN job j ON j.jb_id=i.jb_id JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE j.hq_id=? AND cc.cs_id=? AND i.inv_no=? ORDER BY "+(r.sort().equals("CENTER")?"cc.csc_name,j.jb_id":"j.jb_id")+" LIMIT 501",u.headquartersId(),r.customer(),r.number().strip()); + if(jobs.isEmpty())throw bad("Für diesen Kunden und diese Niederlassung sind keine Aufträge zur Rechnungsnummer vorhanden.");if(jobs.size()>500)throw bad("Die Belegsammlung überschreitet 500 Aufträge."); + var fingerprint=new ArrayList();fingerprint.add(customer);fingerprint.add(jobs);fingerprint.add(r.sort());var parts=new ArrayList();var warnings=new ArrayList();var ids=new HashSet();int size=0; + for(var job:jobs){long id=id(job,"jb_id");if(!ids.add(id))throw bad("Eine Rechnungszuordnung ist mehrfach vorhanden.");if(!text(job,"inv_no").equals(r.number().strip()))throw bad("Die Schreibweise der Rechnungsnummer ist nicht eindeutig. Bitte die Zuordnung prüfen."); + if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",id).isEmpty())throw bad("Ein Auftrag der Rechnung wird gerade neu zugeordnet."); + var stops=db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,cc.csc_name,cc.csc_path,cs.cs_eid FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id LEFT JOIN costcenter cc ON cc.csc_id=t.csc_id LEFT JOIN customer cs ON cs.cs_id=cc.cs_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id LIMIT 101",id); + if(stops.size()>100)throw bad("Auftrag #"+id+" überschreitet 100 Stationen.");var sortIds=new HashSet();for(var stop:stops)if(!sortIds.add(id(stop,"tr_sort")))throw bad("Die Stationsfolge von Auftrag #"+id+" ist mehrdeutig."); + var services=db.queryForList("SELECT ts.*,cc.csc_name,cs.cs_id,cs.cs_eid,c.cmp_comp,c.cmp_comp2 FROM tourservice ts LEFT JOIN costcenter cc ON cc.csc_id=ts.csc_id LEFT JOIN customer cs ON cs.cs_id=cc.cs_id LEFT JOIN company c ON c.cmp_id=cs.cmp_id WHERE ts.jb_id=? ORDER BY ts.csc_id,ts.tr_sort,ts.srv_id,ts.srvt_id LIMIT 2001",id); + if(services.size()>2000)throw bad("Auftrag #"+id+" überschreitet 2.000 Leistungspositionen.");if(services.stream().anyMatch(s->id(s,"cs_id")!=r.customer()))throw bad("Auftrag #"+id+" enthält Kostenanteile eines anderen Kunden. Bitte die Rechnungszuordnung prüfen."); + var drivers=db.queryForList("SELECT u.usr_name,u.usr_firstname FROM courier cr JOIN user u ON u.usr_id=cr.usr_id AND u.hq_id=cr.hq_id WHERE cr.cr_id=?",id(job,"cr_id"));if(drivers.size()>1)throw bad("Der Transporteur eines Auftrags ist nicht eindeutig."); + var vehicles=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=? ORDER BY mt_id",id(job,"vht_id"));var names=vehicles.stream().map(v->text(v,"mt_value")).distinct().toList();if(names.size()>1)throw bad("Eine Fahrzeugart ist mehrdeutig."); + fingerprint.add(List.of(stops,services,drivers,vehicles));var localWarnings=new ArrayList();if(stops.isEmpty())localWarnings.add("Keine Stationen gespeichert.");if(id(job,"jb_status")!=2)localWarnings.add("Dieser zugeordnete Auftrag ist noch nicht erledigt."); + String html=html(job,stops,services,drivers.isEmpty()?Map.of():drivers.getFirst(),names.isEmpty()?"Fahrzeugtyp "+id(job,"vht_id"):names.getFirst(),localWarnings); + size+=html.length();if(size>24_000_000)throw bad("Die Belegsammlung ist zu umfangreich.");warnings.addAll(localWarnings.stream().map(w->"Auftrag #"+id+": "+w).toList()); + parts.add(new Part(id,html,fields("id",id,"center",text(job,"payer_center"),"stations",stops.size()))); + } + return new Snapshot(revision(fingerprint),parts,warnings); + });} + static String row(String label,String value){return value.isBlank()?"":""+escape(label)+""+escape(value).replace("\n","
")+"";} + static String html(Map job,List> stops,List> services,Map driver,String vehicle,List warnings){ + var out=new StringBuilder("

Rechnung ").append(escape(text(job,"inv_no"))).append("

"); + out.append(row("Rechnungsdatum",text(job,"invoice_date"))).append(row("Kostenstelle",text(job,"payer_center"))).append(row("Fahrzeug",vehicle)).append(row("Auftragszeit",time(job,"jb_ordertime"))).append(row("Annahme",time(job,"jb_taketime"))).append(row("Erledigung",time(job,"jb_finishtime"))).append(row("Transporteur",(text(driver,"usr_firstname")+" "+text(driver,"usr_name")).strip())).append(row("Fahrzeugkennung",text(job,"cr_sid"))).append("
"); + if(id(job,"jb_storno")!=0)out.append("

Stornokennzeichen: ").append(id(job,"jb_storno")).append("

"); + for(var stop:stops){out.append("

Station ").append(id(stop,"tr_sort")).append("

").append(row("Firma",text(stop,"tr_comp"))).append(row("Zusatz",text(stop,"tr_comp2"))).append(row("Person",text(stop,"tr_person"))).append(row("Adresse",(text(stop,"ad_street")+" "+text(stop,"tr_hsno")+", "+text(stop,"ad_zipcode")+" "+text(stop,"ad_city")).strip())).append(row("EID",text(stop,"csc_path").toUpperCase(Locale.ROOT).contains("//EXTERN//")?"":text(stop,"cs_eid"))).append(row("Kostenstelle",text(stop,"csc_name"))).append(row("Telefon",text(stop,"tr_phone"))).append(row("Kommissionsnummer",text(stop,"tr_commission_no"))).append(row("Erledigung",dateTime(stop,"tr_finishtime")!=null&&dateTime(stop,"tr_finishtime").getYear()==1970?"":time(stop,"tr_finishtime"))).append(row("Quittungsgeber",text(stop,"tr_signname"))); + if(!text(stop,"tr_sign").isBlank())try{var sign=OrderDeliveryData.signature(text(stop,"tr_sign"));out.append("");}catch(org.springframework.web.server.ResponseStatusException e){String warning="Unterschrift an Station "+id(stop,"tr_sort")+" ist nicht lesbar.";warnings.add(warning);out.append(row("Hinweis",warning));}out.append("
Unterschrift
");} + out.append("

Leistungen

");BigDecimal total=BigDecimal.ZERO; + for(var s:services){BigDecimal amount=BillingService.nz(decimal(s,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(BillingService.nz(decimal(s,"trs_discount")))).movePointLeft(2);total=total.add(amount);out.append("");} + out.append("
Kostenstelle / EID / KundeLeistungNetto
").append(escape(text(s,"csc_name")+" / "+text(s,"cs_eid")+" / "+text(s,"cmp_comp")+" "+text(s,"cmp_comp2"))).append("").append(escape(text(s,"trs_srv_name"))).append("").append(escape(money(amount))).append("

Leistungssumme netto: ").append(escape(money(total))).append("

");if(services.isEmpty())warnings.add("Keine Leistungspositionen gespeichert.");for(String warning:warnings)out.append("

Hinweis: ").append(escape(warning)).append("

");return out.append("").toString(); + } + static byte[] render(Snapshot snapshot){try(var result=new PDDocument()){ + var merger=new PDFMergerUtility();for(var part:snapshot.parts()){byte[] bytes=OrderMailPdf.create(part.id(),part.html());try(var pdf=Loader.loadPDF(bytes)){if(result.getNumberOfPages()+pdf.getNumberOfPages()>1000)throw bad("Die Belegsammlung überschreitet 1.000 Seiten.");merger.appendDocument(result,pdf);}} + result.getDocumentInformation().setTitle("Rechnungsanlage - Auftragsbelege");result.getDocumentInformation().setCreator("Votian NG");var out=new ByteArrayOutputStream();result.save(out);if(out.size()>50*1024*1024)throw bad("Die Belegsammlung überschreitet 50 MB.");return out.toByteArray(); + }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);}} +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyAddressTariffs.java b/backend/src/main/java/de/stadtbote/votian/LegacyAddressTariffs.java index 3b88b8d..4cd6538 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyAddressTariffs.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyAddressTariffs.java @@ -17,15 +17,15 @@ final class LegacyAddressTariffs { } static long area(DatabaseService db,OrderEntryCatalog catalog,long hq,long postal,Station station){ long mapped=mapping(db,catalog,hq,station,true);if(mapped>0)return mapped; - var rows=db.queryForList("SELECT m.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE m.srvp_id=? AND a.hq_id=?",postal,hq); + var rows=db.queryForList("SELECT serviceplzareamapping.srvpa_id FROM serviceplzareamapping JOIN serviceplzarea ON serviceplzarea.srvpa_id=serviceplzareamapping.srvpa_id WHERE serviceplzareamapping.srvp_id=? AND serviceplzarea.hq_id=?",postal,hq); if(rows.size()>1)throw invalid("Die Tarifgebietszuordnung der PLZ "+station.postalCode()+" ist mehrdeutig.");return rows.isEmpty()?0:id(rows.getFirst(),"srvpa_id"); } static long mapping(DatabaseService db,OrderEntryCatalog catalog,long hq,Station station,boolean area){ String digits=station.houseNumber().strip().replaceFirst("[^0-9].*$","");if(digits.isEmpty()||digits.length()>7||station.street().isBlank())return 0; long house=Long.parseLong(digits),lower=0,upper=99999,match=0; String p=area?"srvpaa":"srvpxa",target=area?"srvpa_id":"srvp_id",table=area?"serviceplzareaaddress":"serviceplzaddress"; - String join=area?" JOIN serviceplzarea t ON t.srvpa_id=m.srvpa_id AND t.hq_id=m.hq_id":" JOIN serviceplz t ON t.srvp_id=m.srvp_id"; - var rows=db.queryForList("SELECT m."+p+"_id AS id,m."+target+" AS targetId,m."+p+"_hsno_from AS houseFrom,m."+p+"_hsno_to AS houseTo,m."+p+"_mode AS mode FROM "+table+" m JOIN address a ON a.ad_id=m.ad_id"+join+" WHERE m.hq_id=? AND a.ad_zipcode=? AND COALESCE(NULLIF(a.ad_country,''),'DE')=? AND a.ad_street LIKE ? ESCAPE '!' ORDER BY m."+p+"_id",hq,station.postalCode(),station.country(),AddressAdministration.like(station.street())); + String join=area?" JOIN serviceplzarea ON serviceplzarea.srvpa_id="+table+".srvpa_id AND serviceplzarea.hq_id="+table+".hq_id":" JOIN serviceplz ON serviceplz.srvp_id="+table+".srvp_id"; + var rows=db.queryForList("SELECT "+table+"."+p+"_id AS id,"+table+"."+target+" AS targetId,"+table+"."+p+"_hsno_from AS houseFrom,"+table+"."+p+"_hsno_to AS houseTo,"+table+"."+p+"_mode AS mode FROM "+table+" JOIN address ON address.ad_id="+table+".ad_id"+join+" WHERE "+table+".hq_id=? AND address.ad_zipcode=? AND COALESCE(NULLIF(address.ad_country,''),'DE')=? AND address.ad_street LIKE ? ESCAPE '!' ORDER BY "+table+"."+p+"_id",hq,station.postalCode(),station.country(),AddressAdministration.like(station.street())); for(var row:rows){long from=bound(row,"houseFrom",0),to=bound(row,"houseTo",99999),mode=id(row,"mode");if(from>=lower&&to<=upper&&house>=from&&house<=to&&(mode==0||mode==(house%2==0?2:1))){lower=from;upper=to;match=id(row,"targetId"); if(area){String raw=catalog.parameter(0,"MASK_AREA_ID_FROM_ADDRESS_"+id(row,"id"));for(String pair:raw.split(";")){String[] v=pair.split(",");if(v.length==2&&v[0].equals(station.houseNumber().replace(" ",""))&&v[1].matches("[0-9]{1,9}")){long special=Long.parseLong(v[1]);if(db.queryForList("SELECT srvpa_id FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",special,hq).isEmpty())throw invalid("Der abweichende Hausnummernbereich ist nicht mehr in dieser Niederlassung verfügbar.");match=special;}}} }}return match; diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java b/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java index 5978d41..028870d 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyAuthenticationProvider.java @@ -11,29 +11,64 @@ import java.util.*; @Component public class LegacyAuthenticationProvider implements AuthenticationProvider { private final DatabaseService database; + private final java.time.Clock clock; static final String AUTH_SQL = """ - SELECT u.usr_id, u.hq_id, u.usr_account, e.emp_id, e.emp_rights + SELECT u.usr_id, u.hq_id, u.usr_account, e.emp_id, e.emp_rights, u.usr_totp_activated, u.usr_totp_secret, %s AS credential_stamp FROM user u JOIN employee e ON e.usr_id = u.usr_id JOIN headquarters h ON h.hq_id = u.hq_id WHERE u.usr_account = ? AND u.usr_type = 1 AND u.hq_id > 0 AND EXISTS (SELECT 1 FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id AND mh.md_id>0) AND (u.usr_password = PASSWORD(?) OR u.usr_password_old = OLD_PASSWORD(?)) - """; - public LegacyAuthenticationProvider(DatabaseService database) { this.database = database; } + """.formatted(LegacyCredentialState.EXPRESSION); + static final String PORTAL_AUTH_SQL = AUTH_SQL.replace("u.usr_type = 1", "u.usr_type = 4 AND COALESCE(h.hq_disabled,0)=0 AND (SELECT COUNT(*) FROM stockuser su WHERE su.usr_id=u.usr_id)<=1 AND (SELECT COUNT(*) FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id)=1"); + static final String COURIER_CANDIDATE_SQL="SELECT usr_id,hq_id FROM user WHERE usr_type=3 AND usr_account=? AND (usr_password=PASSWORD(?) OR usr_password_old=OLD_PASSWORD(?))"; + static final String TRANSFER_CANDIDATE_SQL="SELECT usr_id,hq_id FROM user WHERE usr_type>4 AND usr_account=? AND (usr_password=PASSWORD(?) OR usr_password_old=OLD_PASSWORD(?))"; + static final String CUSTOMER_CANDIDATE_SQL="SELECT u.usr_id,u.hq_id,e.emp_id FROM user u JOIN employee e ON e.usr_id=u.usr_id WHERE u.usr_type=2 AND u.usr_account=? AND (u.usr_password=PASSWORD(?) OR u.usr_password_old=OLD_PASSWORD(?))"; + @org.springframework.beans.factory.annotation.Autowired + public LegacyAuthenticationProvider(DatabaseService database) { this(database,java.time.Clock.systemUTC()); } + LegacyAuthenticationProvider(DatabaseService database,java.time.Clock clock) { this.database=database;this.clock=clock; } @Override public Authentication authenticate(Authentication authentication) { String username = authentication.getName(); String password = String.valueOf(authentication.getCredentials()); if (username.isBlank() || password.isBlank() || username.length()>200 || password.length()>1024) throw new BadCredentialsException("Benutzername oder Passwort ist falsch."); try { List> rows = database.queryForList(AUTH_SQL,username,password,password); - if (rows.size() != 1) throw new BadCredentialsException("Benutzername oder Passwort ist falsch, oder das Konto hat keinen Zentralenzugang."); + int accountType=1;if(rows.isEmpty()){rows=database.queryForList(PORTAL_AUTH_SQL,username,password,password);accountType=4;} + if(rows.isEmpty()){var candidates=database.queryForList(COURIER_CANDIDATE_SQL,username,password,password);if(candidates.size()==1){var c=candidates.getFirst();rows=database.queryForList(CourierPortalScope.ACCOUNT_SQL,LegacyMapping.id(c,"usr_id"),0L,LegacyMapping.id(c,"hq_id"));accountType=3;}} + if(rows.isEmpty()){var candidates=database.queryForList(CUSTOMER_CANDIDATE_SQL,username,password,password);if(candidates.size()==1){var c=candidates.getFirst();rows=database.queryForList(CustomerPortalScope.ACCOUNT_SQL,LegacyMapping.id(c,"usr_id"),LegacyMapping.id(c,"emp_id"),LegacyMapping.id(c,"hq_id"));accountType=2;}} + if(rows.isEmpty()){var candidates=database.queryForList(TRANSFER_CANDIDATE_SQL,username,password,password);if(candidates.size()==1){var c=candidates.getFirst();rows=database.queryForList(TransferPortalScope.ACCOUNT_SQL,LegacyMapping.id(c,"usr_id"),0L,LegacyMapping.id(c,"hq_id"));if(rows.size()==1){accountType=(int)LegacyMapping.id(rows.getFirst(),"usr_type");try{var grant=TransferPortalScope.grant(database,accountType,LegacyMapping.id(rows.getFirst(),"hq_id"));rows=new ArrayList<>(rows);var mapped=new LinkedHashMap<>(rows.getFirst());mapped.put("emp_rights",AdminSupport.revision(grant));rows.set(0,mapped);}catch(org.springframework.security.access.AccessDeniedException|org.springframework.web.server.ResponseStatusException failure){throw new BadCredentialsException("Der Datentransfer-Zugang ist nicht eindeutig freigegeben.");}}}} + if (rows.size() != 1) throw new BadCredentialsException("Benutzername oder Passwort ist falsch, oder das Konto ist nicht freigegeben."); Map row = rows.getFirst(); - LegacyUser user = new LegacyUser(LegacyMapping.id(row,"usr_id"),LegacyMapping.id(row,"emp_id"),LegacyMapping.id(row,"hq_id"),LegacyMapping.text(row,"usr_account"),LegacyMapping.text(row,"emp_rights")); - if (user.headquartersId() <= 0 || user.employeeId() <= 0) throw new BadCredentialsException("Dem Konto fehlt eine eindeutige Niederlassung oder Mitarbeiterzuordnung."); - return UsernamePasswordAuthenticationToken.authenticated(user,null,List.of(new SimpleGrantedAuthority("ROLE_VIEWER"))); + LegacyUser user = new LegacyUser(LegacyMapping.id(row,"usr_id"),LegacyMapping.id(row,"emp_id"),LegacyMapping.id(row,"hq_id"),LegacyMapping.text(row,"usr_account"),LegacyMapping.text(row,"emp_rights"),LegacyMapping.id(row,"hq_id"),accountType); + if (user.headquartersId() <= 0 || !user.courierAccount()&&!user.transferAccount()&&user.employeeId() <= 0) throw new BadCredentialsException("Dem Konto fehlt eine eindeutige Niederlassung oder Kontozuordnung."); + var result=UsernamePasswordAuthenticationToken.authenticated(user,null,List.of(new SimpleGrantedAuthority(user.transferAccount()?"ROLE_TRANSFER":user.customerAccount()?"ROLE_CUSTOMER":user.courierAccount()?"ROLE_COURIER":user.warehouseAccount()?"ROLE_WAREHOUSE":"ROLE_VIEWER"))); + String stamp=LegacyMapping.text(row,"credential_stamp"); + if(LegacyMapping.id(row,"usr_totp_activated")==1) stamp=verifySecondFactor(row,authentication); + result.setDetails(new LegacyCredentialState(stamp)); + return result; } catch (DataAccessException ex) { throw new AuthenticationServiceException(ApiErrors.DATABASE_UNAVAILABLE); } } + static final String FACTOR_SQL="SELECT "+LegacyCredentialState.EXPRESSION+" AS credential_stamp,usr_totp_secret,usr_totp_activated FROM user WHERE usr_id=?"; + String verifySecondFactor(Map original,Authentication request){ + String code=request.getDetails() instanceof LegacyTotp.LoginDetails details?details.code():null; + if(!LegacyTotp.matches(LegacyMapping.text(original,"usr_totp_secret"),code,clock.instant().getEpochSecond()))throw new BadCredentialsException("Anmeldung oder Zwei-Faktor-Code ungültig."); + for(String table:List.of("user","genericdatacontainer"))if(!Objects.equals(database.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND ENGINE='InnoDB'",Integer.class,table),1))throw new AuthenticationServiceException("Die Anmeldung benötigt transaktionale Kontotabellen."); + return database.authenticationWriteTransaction(()->{ + long uid=LegacyMapping.id(original,"usr_id"),seconds=clock.instant().getEpochSecond(),step=seconds/30; + var rows=database.queryForList(FACTOR_SQL,uid); + if(rows.size()!=1)throw new BadCredentialsException("Anmeldung oder Zwei-Faktor-Code ungültig.");var live=rows.getFirst(); + String secret=LegacyMapping.text(live,"usr_totp_secret"); + if(!LegacyMapping.text(live,"credential_stamp").equals(LegacyMapping.text(original,"credential_stamp"))||LegacyMapping.id(live,"usr_totp_activated")!=1||!LegacyTotp.matches(secret,code,seconds))throw new BadCredentialsException("Anmeldung oder Zwei-Faktor-Code ungültig."); + String binding=AdminSupport.revision(secret);var consumed=database.queryForList("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngtotp' AND gdc_obj_id=? AND gdc_gen_fieldname='login'",uid); + if(consumed.size()>1)throw new AuthenticationServiceException("Die Zwei-Faktor-Konfiguration ist nicht eindeutig."); + if(!consumed.isEmpty()&&binding.equals(LegacyMapping.text(consumed.getFirst(),"gdc_context"))){long previous;try{previous=Long.parseLong(LegacyMapping.text(consumed.getFirst(),"gdc_content"));}catch(NumberFormatException e){throw new AuthenticationServiceException("Die Zwei-Faktor-Konfiguration ist ungültig.");}if(previous>=step)throw new BadCredentialsException("Bitte den nächsten Zwei-Faktor-Code verwenden.");} + if(consumed.isEmpty())database.insert("genericdatacontainer",OrderEntryService.fields("gdc_obj_type","ngtotp","gdc_obj_id",uid,"gdc_gen_fieldname","login","gdc_content",Long.toString(step),"gdc_context",binding)); + else database.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",Long.toString(step),binding,LegacyMapping.id(consumed.getFirst(),"gdc_id")); + database.update("UPDATE user SET usr_totp_sessionkey=? WHERE usr_id=?",UUID.randomUUID().toString().replace("-",""),uid); + return LegacyMapping.text(AdminSupport.one(database.queryForList(FACTOR_SQL,uid)),"credential_stamp"); + }); + } @Override public boolean supports(Class authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } } diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyCredentialState.java b/backend/src/main/java/de/stadtbote/votian/LegacyCredentialState.java new file mode 100644 index 0000000..7dfac30 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacyCredentialState.java @@ -0,0 +1,7 @@ +package de.stadtbote.votian; + +/** Server-only session binding. Never serialized in the public user response. */ +record LegacyCredentialState(String fingerprint) implements java.io.Serializable { + static final String EXPRESSION="SHA2(CONCAT_WS('|',COALESCE(usr_password,''),COALESCE(usr_password_old,''),COALESCE(usr_password_modify,''),COALESCE(usr_totp_secret,''),COALESCE(usr_totp_activated,0),COALESCE(usr_totp_sessionkey,'')),256)"; + @Override public String toString(){return "LegacyCredentialState[redacted]";} +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java b/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java index f576377..2ec67ef 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyDistancePricing.java @@ -10,7 +10,7 @@ import static de.stadtbote.votian.OrderEntryCatalog.invalid; /** ajaxReqKmPrice.php: distance/zone rates, approach/departure, minimum and adoption rounding. */ final class LegacyDistancePricing { - record Price(BigDecimal base,BigDecimal courier,List lines) {} + record Price(BigDecimal base,BigDecimal courier,List lines,BigDecimal comparisonKm) {} private final OrderEntryCatalog catalog; private final LegacyOrderPricing tariffs; LegacyDistancePricing(OrderEntryCatalog catalog,LegacyOrderPricing tariffs){this.catalog=catalog;this.tariffs=tariffs;} @@ -18,6 +18,7 @@ final class LegacyDistancePricing { if(route==null)throw invalid("Für diesen Preis muss zunächst die Strecke berechnet werden."); long hq=route.info().pricingHeadquartersId();boolean zone=request.pricing().mode()==PriceMode.ZONE; boolean longhaul=route.info().longhaul(); + BigDecimal comparisonKm=route.info().distanceKm(); List lines=new ArrayList<>();BigDecimal customer=BigDecimal.ZERO,courier=BigDecimal.ZERO; Tariff minimum=new Tariff(BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,false); if(!zone) { @@ -57,6 +58,7 @@ final class LegacyDistancePricing { BigDecimal quantity=key.contains("_fix")?BigDecimal.ONE:OrderRoutingService.kilometers(entry.getValue()); BigDecimal charge=quantity.multiply(rate),crCharge=quantity.multiply(crRate); if(group.equals("zoneDistance") || key.contains("_fix")){charge=money(charge);crCharge=money(crCharge);} + if(!group.equals("zoneDistance") && charge.signum()!=0)comparisonKm=comparisonKm.add(OrderRoutingService.kilometers(entry.getValue())); customer=customer.add(charge);courier=courier.add(crCharge); String label=(group.equals("extraApproachDistance")?"Anfahrt · ":group.equals("extraDepartureCalculation")?"Abfahrt · ":"")+name.replace("
"," "); lines.add(new CalculationLine(label,quantity,rate,money(charge))); @@ -85,7 +87,7 @@ final class LegacyDistancePricing { BigDecimal adopted=(!zone || (catalog.enabled(hq,"PZM_MASK_ROUND") && !catalog.enabled(0,"PZM_MASK_DONT_ROUND_"+cs))) ?customer.setScale(0,RoundingMode.CEILING):money(customer); if(adopted.compareTo(customer)!=0)lines.add(new CalculationLine("Aufrundung",BigDecimal.ONE,adopted.subtract(customer),adopted.subtract(customer))); - return new Price(money(adopted),courier,List.copyOf(lines)); + return new Price(money(adopted),courier,List.copyOf(lines),comparisonKm); } static String legacyType(Request request,OrderRoutingService.Result route,Price price) { if(request.pricing().mode()==PriceMode.ZONE) { diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java b/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java index 8a17d9d..9eddadc 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyMapping.java @@ -52,6 +52,7 @@ final class LegacyMapping { } static DeliveryOrder order(Map row) { DeliveryOrder o=new DeliveryOrder();o.id=id(row,"jb_id");o.number=Long.toString(o.id);o.version=id(row,"jb_version"); + o.headquartersId=id(row,"hq_id"); o.customerId=nullableId(row,"app_customer_id");o.customerName=text(row,"app_customer_name"); o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.ordererCustomerId=nullableId(row,"app_orderer_customer_id");o.courierId=nullableId(row,"cr_id"); o.legacyStatus=(int)id(row,"jb_status");int storno=(int)id(row,"jb_storno");o.legacyStorno=storno;o.isCreditNote=storno==3||storno==4; @@ -70,7 +71,7 @@ final class LegacyMapping { // Existing VoTiAn tour field is preserved through the explicit warehouse direction mapping. // job_options.php and get_saved_tour.php: bit 2 = collection, bit 1 = delivery. // The old get_tr_ware_from_to_text helper contains the inverse labels. - s.type=switch((int)id(row,"tr_ware_from_to")){case 2->StopType.PICKUP;case 1->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;}; + s.type=switch((int)id(row,"tr_ware_from_to")){case 0->StopType.NONE;case 2->StopType.PICKUP;case 1->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;}; s.name=join(text(row,"tr_comp"),text(row,"tr_comp2"));s.street=join(text(row,"ad_street"),text(row,"tr_hsno")); s.postalCode=text(row,"ad_zipcode");s.city=text(row,"ad_city");s.contact=text(row,"tr_person");s.phone=text(row,"tr_phone");s.note=text(row,"tr_remark"); s.completedAt=id(row,"tr_status")==1?dateTime(row,"tr_finishtime"):null;s.recipientName=text(row,"tr_signname");return s; diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java b/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java index f2854bd..5a88974 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyOrderPricing.java @@ -9,6 +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.*; @@ -37,7 +38,14 @@ public class LegacyOrderPricing { return calculate(user,request,payer,at,routing,photos,null); } Quote calculate(LegacyUser user,Request request,Map payer,LocalDateTime at,OrderRoutingService.Result routing,List photos,PhotoSet photoSet) { + return calculateInternal(user,request,payer,at,routing,photos,photoSet,null,true); + } + Quote calculateExisting(LegacyUser user,Request request,Map payer,LocalDateTime at,OrderRoutingService.Result routing,BigDecimal storedDiscount) { + return calculateInternal(user,request,payer,at,routing,List.of(),null,storedDiscount,false); + } + private Quote calculateInternal(LegacyUser user,Request request,Map payer,LocalDateTime at,OrderRoutingService.Result routing,List photos,PhotoSet photoSet,BigDecimal storedDiscount,boolean newOrder) { Pricing input=request.pricing(); + BigDecimal selfDiscount=storedDiscount!=null?storedDiscount:user.customerAccount()?new CustomerEntryAccess(db).selfDiscount(user,id(CustomerPortalScope.verify(db,user),"cs_id")):ZERO; RouteInfo route=routing==null?null:routing.info(); long hq=route!=null && (input.mode()==PriceMode.KILOMETER || input.mode()==PriceMode.ZONE)?route.pricingHeadquartersId():user.headquartersId(); Map customer=payer; @@ -47,6 +55,7 @@ public class LegacyOrderPricing { if(id(origin,"cs_jbcond")==1) customer=origin; } long cs=id(customer,"cs_id"); + var standardPricing=CustomerStandardPricing.read(db,catalog,user,cs); String vehicle=text(one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?", request.vehicleTypeId()),"Bitte einen gültigen Fahrzeugtyp wählen."),"mt_value"); String override=catalog.parameter(0,"COSTCENTER_INV_MODE_"+request.payerCostCenterId()); @@ -59,7 +68,7 @@ public class LegacyOrderPricing { } BigDecimal courierMarkup=input.courierMarkup()!=null?input.courierMarkup():fuelCourierMarkup(hq,cs,vehicle,at,customer); if(Set.of(2L,3L,5L).contains(hq) && at.isBefore(LocalDateTime.of(2021,11,16,0,0)) && markup.signum()>0) markup=ZERO; - BigDecimal discount=input.applyFixedDiscount()?number(customer,"cs_fixprice_discount"):ZERO; + BigDecimal discount=input.applyFixedDiscount()?number(customer,"cs_discount"):ZERO; BigDecimal deduction=courierDeduction(hq,cs,vehicle,at); List components=new ArrayList<>(); BigDecimal courierBase=ZERO; @@ -74,23 +83,26 @@ public class LegacyOrderPricing { 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); courierBase=cr; - components.add(new Component(0,"Fixpreis",automatic?LegacyDistancePricing.legacyType(request,routing,autoPrice):"",markup(base,markup),discount,discount(markup(base,markup),discount), - discount(base,discount),markup(cr,courierMarkup),cr)); - } else { + BigDecimal publicBase=markup(base,markup); + // jobs2 subtracts the rounded self-service amount before the fixed-price discount. + BigDecimal publicPrice=publicBase.subtract(money(publicBase.multiply(selfDiscount).divide(HUNDRED))); + components.add(new Component(0,"Fixpreis",automatic?LegacyDistancePricing.legacyType(request,routing,autoPrice):"",publicPrice,discount,discount(publicPrice,discount), + discount(discount(base,discount),selfDiscount),markup(cr,courierMarkup),cr)); + } else if(!standardPricing.disabled()) { // The active PHP saveServiceCosts branch only implements PLZ/area tariffs. if(invoiceMode<1) throw invalid("Für diesen Kunden ist kein PLZ-/Gebietstarif hinterlegt. Bitte einen kalkulierten Festpreis eingeben."); Tariff basic=service(hq,cs,vehicle,"Grundpreis",at,number(customer,"cs_discount")); boolean noBasicMarkup=catalog.enabled(hq,"MASK_NO_MARKUP_ON_VEHICLE_PRICE"); - components.add(component(0,"Grundpreis",vehicle,basic,noBasicMarkup?ZERO:markup,noBasicMarkup?ZERO:courierMarkup,deduction)); + components.add(component(0,"Grundpreis",vehicle,basic,noBasicMarkup?ZERO:markup,noBasicMarkup?ZERO:courierMarkup,deduction,selfDiscount)); int mappedVehicle=mappedVehicle(hq,request.vehicleTypeId()); for(int i=1;i(components.stream().map(CustomerPendingPrice::zeroBasic).toList());} + // Both PHP entry points call check_jb_serviceprice only when job_is_new. + BigDecimal automaticService=newOrder?streetService(hq,request,at):ZERO; if(automaticService.signum()>0) { components.removeIf(c -> c.name().equals("Servicepreis")); components.add(new Component(0,"Servicepreis","",service.add(automaticService),ZERO, discount(service,serviceDiscount).add(automaticService),discount(service,serviceDiscount).add(automaticService),courierService,courierService)); net=net.add(automaticService);subtotal=subtotal.add(automaticService); } + if(selfDiscount.signum()>0 && catalog.enabled(hq,"MASK_CR_PRICE_MODE") && customerCourierModeActive(catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE"),at) + && (storedDiscount==null || input.mode()==PriceMode.FIXED || automatic || positive(input.courierPrice()))) { + // jobs2 discounts the subtotal again; central job_options keeps an already determined tariff freight amount. + courierSubtotal=discount(courierSubtotal.subtract(courierService),selfDiscount).add(courierService); + courierNet=markup(courierSubtotal.subtract(courierService),courierMarkup).add(courierService); + } BigDecimal courierAgio=ZERO; if(request.mode()==Mode.RETROSPECTIVE && request.courierVehicleId()!=null && deduction.signum()>0) { var rates=db.queryForList(""" @@ -148,7 +170,10 @@ public class LegacyOrderPricing { } } if(batch) {net=automaticService;subtotal=automaticService;courierNet=ZERO;courierSubtotal=ZERO;courierBase=ZERO;courierService=ZERO;} + LonghaulBasis longhaulBasis=null; if(route!=null && route.longhaul() && request.mode()!=Mode.RETROSPECTIVE) { + // job_options.php preserves this before deferring payment until BWV assignment. + longhaulBasis=new LonghaulBasis(money(courierSubtotal.subtract(courierService)),money(courierService)); courierNet=ZERO;courierSubtotal=ZERO;courierBase=ZERO;courierService=ZERO; components=components.stream().map(c -> new Component(c.sequence(),c.name(),c.type(),c.price(),c.discount(),c.net(),c.subtotal(),ZERO,ZERO)).toList(); } @@ -164,12 +189,12 @@ public class LegacyOrderPricing { BigDecimal taxAmount=money(payableNet.multiply(taxRate).divide(HUNDRED)); String limitText=catalog.parameter(0,"JB_CR_PRICE_BLOCK"); BigDecimal limit=limitText.isBlank()?ZERO:new BigDecimal(limitText.replace(',','.')); - boolean limitExceeded=limit.signum()>0 && courierNet.compareTo(money(net.multiply(limit).divide(HUNDRED)))>0; + boolean limitExceeded=!pricePending && limit.signum()>0 && courierNet.compareTo(money(net.multiply(limit).divide(HUNDRED)))>0; Quote quote=new Quote(money(net),taxAmount,money(payableNet.add(taxAmount)),money(subtotal),money(courierNet),money(courierSubtotal), - markup,courierMarkup,discount,taxRate,taxSign,money(courierBase),money(courierService),deduction,invoiceMode,batch,List.copyOf(components),"",courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet); - String fingerprint=hash(user.headquartersId()+"|"+quote.toString()+"|"+request.insurance()); - return new Quote(quote.net(),quote.tax(),quote.gross(),quote.subtotal(),quote.courierNet(),quote.courierSubtotal(), - markup,courierMarkup,discount,taxRate,taxSign,quote.courierBase(),quote.courierService(),deduction,invoiceMode,batch,quote.components(),fingerprint,courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet); + markup,courierMarkup,discount,taxRate,taxSign,money(courierBase),money(courierService),deduction,invoiceMode,batch,List.copyOf(components),"",courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet,longhaulBasis,null,null,pricePending); + String fingerprint=hash(user.headquartersId()+"|"+quoteEvidence(new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().valueToTree(quote))+"|"+request.insurance()+(user.customerAccount()?"|"+standardPricing:"")); + return OrderCourierThreshold.apply(catalog,user,request,new Quote(quote.net(),quote.tax(),quote.gross(),quote.subtotal(),quote.courierNet(),quote.courierSubtotal(), + markup,courierMarkup,discount,taxRate,taxSign,quote.courierBase(),quote.courierService(),deduction,invoiceMode,batch,quote.components(),fingerprint,courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet,longhaulBasis,null,null,pricePending)); } private BigDecimal streetService(long hq,Request request,LocalDateTime at) { @@ -193,13 +218,28 @@ public class LegacyOrderPricing { } static Component component(int sequence,String name,String type,Tariff tariff,BigDecimal markup,BigDecimal courierMarkup,BigDecimal deduction) { + return component(sequence,name,type,tariff,markup,courierMarkup,deduction,ZERO); + } + static boolean customerCourierModeActive(String date,LocalDateTime at){ + if(date.isBlank())return true; + try{return CourierPriceModeDate.active(date,at,true);} + catch(RuntimeException e){throw invalid("Das Startdatum des Frachtvergütungsmodus ist ungültig.");} + } + static Component component(int sequence,String name,String type,Tariff tariff,BigDecimal markup,BigDecimal courierMarkup,BigDecimal deduction,BigDecimal selfDiscount) { BigDecimal cr=tariff.courier().signum()==0?discount(tariff.price(),deduction):tariff.courier(); return new Component(sequence,name,type,markup(tariff.price(),markup),tariff.discount(), discount(markup(tariff.price(),markup),tariff.discount()),discount(tariff.price(),tariff.discount()), - discount(markup(cr,courierMarkup),tariff.discount()),discount(cr,tariff.discount())); + discount(markup(cr,courierMarkup),tariff.discount().add(selfDiscount)),discount(cr,tariff.discount().add(selfDiscount))); } Tariff service(long hq,long cs,String vehicle,String name,LocalDateTime at,BigDecimal customerDiscount) { + return service(hq,cs,vehicle,name,at,customerDiscount,true); + } + // get_cr_price_longhaul uses the raw getServicePrices return, without its correction fields. + Tariff longhaulTariff(long hq,long cs,String vehicle,LocalDateTime at) { + return service(hq,cs,vehicle,"Rund-km-Preis",at,ZERO,false); + } + private Tariff service(long hq,long cs,String vehicle,String name,LocalDateTime at,BigDecimal customerDiscount,boolean corrections) { var type=db.queryForList("SELECT srvt_id,hq_id FROM servicetype WHERE srvt_name=? AND srvt_mode=0 AND hq_id IN (?,0) ORDER BY hq_id DESC LIMIT 1",vehicle,hq); if(type.isEmpty()) return new Tariff(ZERO,ZERO,ZERO,false); long tariffHq=id(type.getFirst(),"hq_id"),typeId=id(type.getFirst(),"srvt_id"); @@ -208,6 +248,7 @@ public class LegacyOrderPricing { long serviceId=id(services.getFirst(),"srv_id"); Tariff specific=serviceValues(tariffHq,cs,serviceId,typeId,at); Tariff common=specific.price().signum()<=0?serviceValues(tariffHq,0,serviceId,typeId,at):specific; + if(!corrections)return new Tariff(placeholder(common.price()),ZERO,placeholder(common.courier()),common.found()); Tariff resolved=resolve(specific,common,customerDiscount,false); return new Tariff(placeholder(resolved.price()),resolved.discount(),placeholder(resolved.courier()),resolved.found()); } @@ -220,6 +261,31 @@ public class LegacyOrderPricing { """,hq,cs,service,type,at)); } + record BatchAmounts(BigDecimal freightWithMarkup,BigDecimal subtotal,BigDecimal net,BigDecimal discount, + BigDecimal markup,BigDecimal courierMarkup,BigDecimal courierBase,BigDecimal courierService,BigDecimal courierNet) {} + + /** jb_edit_batch.php has its own formula: service also contributes to the courier base. */ + BatchAmounts batch(long hq,Map customer,String vehicle,LocalDateTime at, + BigDecimal freight,BigDecimal service,boolean useDiscount,boolean useMarkup) { + long cs=id(customer,"cs_id"); + BigDecimal d=useDiscount&&!catalog.enabled(hq,"JB_EDITBATCH_DISCOUNT_DISABLED")?number(customer,"cs_discount").max(ZERO):ZERO; + if(d.compareTo(HUNDRED)>0)throw invalid("Der Kundenrabatt muss zwischen 0 und 100 Prozent liegen."); + BigDecimal m=fuelMarkup(hq,cs,vehicle,at,customer); + boolean allowed=useMarkup&&id(customer,"cs_markup")==1; + if(!allowed&&m.signum()>0)m=ZERO; + BigDecimal withMarkup=freight.multiply(HUNDRED.add(m.max(ZERO))).divide(HUNDRED); + BigDecimal subtotal=freight.multiply(HUNDRED.subtract(d)).divide(HUNDRED).add(service); + BigDecimal net=withMarkup.multiply(HUNDRED.subtract(d)).divide(HUNDRED).add(service); + BigDecimal cm=ZERO,base=ZERO,crService=ZERO,crNet=ZERO; + if(catalog.enabled(hq,"MASK_CR_PRICE_MODE")) { + BigDecimal deduction=courierDeduction(hq,cs,vehicle,at); + base=discount(subtotal,deduction);crService=discount(service,deduction); + cm=fuelCourierMarkup(hq,cs,vehicle,at,customer);if(!allowed&&cm.signum()>0)cm=ZERO; + crNet=base.multiply(HUNDRED.add(cm.max(ZERO))).divide(HUNDRED).add(crService); + } + return new BatchAmounts(withMarkup,subtotal,net,d,m,cm,base,crService,crNet); + } + private BigDecimal fuelMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map customer) { if(id(customer,"cs_markup")!=1) return ZERO; BigDecimal direct=number(customer,"cs_markup_prov"); @@ -232,23 +298,54 @@ public class LegacyOrderPricing { return tariff.price().multiply(HUNDRED.subtract(tariff.discount())).divide(HUNDRED); } - private BigDecimal fuelCourierMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map customer) { + BigDecimal fuelCourierMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map customer) { Tariff tariff=service(hq,cs,vehicle,"Aufschlag
Sprit [%]",at,ZERO); return tariff.courier().signum()==0?fuelMarkup(hq,cs,vehicle,at,customer):tariff.courier(); } - private BigDecimal courierDeduction(long hq,long cs,String vehicle,LocalDateTime at) { + BigDecimal courierDeduction(long hq,long cs,String vehicle,LocalDateTime at) { String sql=""" - SELECT h.srvh_cr_price AS price,h.srvh_discount AS discount,h.srvh_cr_price AS courier - FROM servicehistory h JOIN service s ON s.srv_id=h.srv_id JOIN servicetype t ON t.srvt_id=h.srvt_id - WHERE h.hq_id=? AND h.cs_id=? AND s.hq_id=h.hq_id AND t.hq_id=h.hq_id + SELECT tariffHistory.srvh_cr_price AS price,tariffHistory.srvh_discount AS discount,tariffHistory.srvh_cr_price AS courier + FROM servicehistory tariffHistory JOIN service s ON s.srv_id=tariffHistory.srv_id JOIN servicetype tariffType ON tariffType.srvt_id=tariffHistory.srvt_id + WHERE tariffHistory.hq_id=? AND tariffHistory.cs_id=? AND s.hq_id=tariffHistory.hq_id AND tariffType.hq_id=tariffHistory.hq_id AND s.srv_name='Globaler
Frachtverg.-Abschlag [%]' AND s.srv_mode=0 - AND t.srvt_mode=0 AND t.srvt_name=? AND h.srvh_validfrom<=? ORDER BY h.srvh_validfrom DESC LIMIT 1 + AND tariffType.srvt_mode=0 AND tariffType.srvt_name=? AND tariffHistory.srvh_validfrom<=? ORDER BY tariffHistory.srvh_validfrom DESC LIMIT 1 """; BigDecimal rate=values(db.queryForList(sql,hq,cs,vehicle,at)).price(); return rate.signum()==0?values(db.queryForList(sql,hq,0,vehicle,at)).price():rate; } + OrderEntryTools.PostalQuote postalQuote(LegacyUser user,OrderEntryTools.PostalInput input) { + user.require(7); + return db.readTransaction(() -> { + long hq=user.headquartersId();var at=LocalDateTime.now(java.time.ZoneId.of("Europe/Berlin")); + String vehicle=text(one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",input.vehicleTypeId()),"Bitte einen gültigen Fahrzeugtyp wählen."),"mt_value"); + BigDecimal surcharge=input.markup()==null?fuelMarkup(hq,0,vehicle,at,Map.of()):input.markup(); + var headquarters=catalog.headquarters(user);int mode=(int)id(headquarters,"hq_invmode"); + if(mode<1)mode=1; + List modes=hq==3?List.of(2,1):List.of(mode); + int mapped=mappedVehicle(hq,input.vehicleTypeId()); + var basic=service(hq,0,vehicle,"Grundpreis",at,ZERO); + BigDecimal base=discount(markup(basic.price(),catalog.enabled(hq,"MASK_NO_MARKUP_ON_VEHICLE_PRICE")?ZERO:surcharge),basic.discount()); + var results=new ArrayList(); + for(int selected:modes) { + var legs=new ArrayList();BigDecimal total=base; + for(int i=1;i components,java.util.function.Function field) { return components.stream().map(field).reduce(ZERO,BigDecimal::add); } + 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"); + return copy.toString(); + } static String hash(String text) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); } catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); } diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java b/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java index 46583ab..276f128 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyReadService.java @@ -32,19 +32,37 @@ public class LegacyReadService { (SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=jb.vht_id) AS vehicle_label, (SELECT GROUP_CONCAT(DISTINCT mt.mt_value ORDER BY mt.mt_sort SEPARATOR ', ') FROM metatype mt WHERE mt.mt_type='service' AND (jb.jb_service & (1 << mt.mt_sort)) <> 0) AS service_label """; - public record OrderResult(List orders,long total,int limit) {} + public record OrderResult(List orders,long total,int limit,List columns,String warning,Set actions,OrderListCells.Sorting sorting,OrderListTotals totals) { public OrderResult(List orders,long total,int limit){this(orders,total,limit,List.of(),"",Set.of(),null,null);} } public OrderResult orders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) { user.require(4); if(from!=null && to!=null && from.isAfter(to)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Das Startdatum liegt nach dem Enddatum."); if(q!=null && !q.isBlank() && q.length()>200) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang."); - return database.readTransaction(() -> readOrders(user,q,status,customerId,courierId,from,to)); + return database.readTransaction(() -> readOrders(user,q,status,customerId,courierId,from,to,maxResults,null,null)); } - private OrderResult readOrders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) { - StringBuilder where=new StringBuilder(" WHERE jb.hq_id = ? ");List parameters=new ArrayList<>(List.of(user.headquartersId())); - if(from!=null){where.append(" AND jb.jb_ordertime >= ?");parameters.add(from.atStartOfDay());} - if(to!=null){where.append(" AND jb.jb_ordertime < ?");parameters.add(to.plusDays(1).atStartOfDay());} + public record ListQuery(Long numberFrom,Long numberTo,Integer vehicleType,int offset,Integer listStatus,String sort,boolean descending,boolean columns,Map filters){public ListQuery { filters=OrderListFilters.validate(filters); } public ListQuery(Long from,Long to,Integer vehicle,int offset,Integer status,String sort,boolean descending,boolean columns){this(from,to,vehicle,offset,status,sort,descending,columns,Map.of());} public ListQuery(Long from,Long to,Integer vehicle,int offset){this(from,to,vehicle,offset,null);} public ListQuery(Long from,Long to,Integer vehicle,int offset,Integer status){this(from,to,vehicle,offset,status,null,false,false);}} + public OrderResult personalOrders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,ListQuery list) { + user.require(4); + if(list!=null&&list.listStatus()!=null&&(status!=null||!EmployeeParallelLists.ALLOWED.contains(list.listStatus())))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Ungültiger oder widersprüchlicher Listenstatus."); + if(list==null||list.offset()<0||list.offset()>10000000||list.numberFrom()!=null&&list.numberFrom()<=0||list.numberTo()!=null&&list.numberTo()<=0||list.numberFrom()!=null&&list.numberTo()!=null&&list.numberFrom()>list.numberTo()||list.vehicleType()!=null&&list.vehicleType()<0)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Ungültiger Nummernbereich, Fahrzeugfilter oder Seitenbeginn."); + if(list.numberFrom()==null&&list.numberTo()==null&&!OrderListFilters.on(list.filters(),"future")&&from!=null&&to!=null&&from.isAfter(to))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Das Startdatum liegt nach dem Enddatum."); + if(q!=null&&q.length()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang."); + return database.readTransaction(()->{var prefs=EmployeeListPreferences.runtime(database,user);return readOrders(user,q,status,customerId,courierId,from,to,prefs.pageSize(),list,prefs);}); + } + private OrderResult readOrders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to,int limit,ListQuery list,EmployeeListPreferences.Values prefs) { + var hqScope=list==null?null:OrderListHeadquarters.checked(database,user,list.filters().get("headquarters")); + StringBuilder where=new StringBuilder();List parameters=new ArrayList<>(); + if(hqScope==null){where.append(" WHERE jb.hq_id = ? ");parameters.add(user.headquartersId());}else hqScope.append(where,parameters); + if((list==null||list.numberFrom()==null&&list.numberTo()==null)&&from!=null){where.append(" AND jb.jb_ordertime >= ?");parameters.add(from.atStartOfDay());} + if((list==null||list.numberFrom()==null&&list.numberTo()==null)&&to!=null&&(list==null||!OrderListFilters.on(list.filters(),"future"))){where.append(" AND jb.jb_ordertime < ?");parameters.add(to.plusDays(1).atStartOfDay());} + if(list!=null){ + if(list.numberFrom()!=null){where.append(" AND jb.jb_id >= ?");parameters.add(list.numberFrom());} + if(list.numberTo()!=null){where.append(" AND jb.jb_id <= ?");parameters.add(list.numberTo());} + if(list.vehicleType()!=null&&list.vehicleType()>0){where.append(" AND jb.vht_id = ?");parameters.add(list.vehicleType());} + else if(prefs.vehicleMax()>0){where.append(" AND jb.vht_id <= ?");parameters.add(prefs.vehicleMax());} + } if(customerId!=null){where.append(" AND customer_cc.cs_id = ?");parameters.add(customerId);} if(courierId!=null){where.append(" AND jb.cr_id = ?");parameters.add(courierId);} + if(list!=null&&list.listStatus()!=null&&list.listStatus()!=10){where.append(" AND jb.jb_status = ?");parameters.add(list.listStatus());} if(status!=null) { if(status==OrderStatus.CANCELLED) where.append(" AND jb.jb_storno IN (1,2,3,4)"); else { @@ -56,10 +74,19 @@ public class LegacyReadService { where.append(" AND (CAST(jb.jb_id AS CHAR) LIKE ? OR jb.jb_commission_no LIKE ? OR cmp.cmp_comp LIKE ? OR EXISTS (SELECT 1 FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND (t.tr_comp LIKE ? OR a.ad_city LIKE ? OR a.ad_street LIKE ?)))"); String term="%"+q.trim()+"%";for(int i=0;i<6;i++)parameters.add(term); } + if(list!=null)OrderListFilters.append(database,user,list.filters(),list.listStatus(),status,where,parameters); Long total=database.queryForObject("SELECT COUNT(*) "+ORDER_FROM+where,Long.class,parameters.toArray()); - List result=database.queryForList(ORDER_SELECT+ORDER_FROM+where+" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT "+maxResults,parameters.toArray()).stream().map(LegacyMapping::order).toList(); + boolean notes=prefs!=null&&prefs.courierNotes()&&user.hasRight(1)&&(status==OrderStatus.IN_PROGRESS||status==OrderStatus.COMPLETED||list!=null&&list.listStatus()!=null&&Set.of(1,2).contains(list.listStatus())); + var layout=list!=null&&list.columns()?OrderListCells.layout(database,user,status,list,prefs):null; + String select=ORDER_SELECT; + if(layout!=null)select+=OrderListCells.projection(layout)+", (SELECT pcs.hq_id FROM customer pcs WHERE pcs.cs_id=payer.cs_id) AS list_payer_hq "; + if(notes)select+=",(SELECT LEFT(c.cmp_remark,20) FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.cr_id=jb.cr_id AND cr.hq_id=jb.hq_id) AS app_courier_remark,(SELECT LEFT(c.cmp_remark2,20) FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.cr_id=jb.cr_id AND cr.hq_id=jb.hq_id) AS app_courier_internal_remark "; + var source=database.queryForList(select+ORDER_FROM+where+" ORDER BY "+(layout==null?"jb.jb_ordertime DESC,jb.jb_id DESC":OrderListCells.sort(layout))+" LIMIT "+limit+(list==null?"":" OFFSET "+list.offset()),parameters.toArray()); + List result=source.stream().map(row->{var order=LegacyMapping.order(row);if(layout!=null){OrderListCells.fill(order,row,layout);order.listPayerHeadquarters=LegacyMapping.id(row,"list_payer_hq");}if(notes){order.courierRemark=LegacyMapping.text(row,"app_courier_remark");order.courierInternalRemark=LegacyMapping.text(row,"app_courier_internal_remark");}return order;}).toList(); addStops(result); - return new OrderResult(result,total==null?0:total,maxResults); + if(hqScope!=null)result.forEach(o->o.headquartersLabel=hqScope.label(o.headquartersId)); + if(layout!=null){OrderListCells.verifySources(database,result,layout);result.forEach(OrderListCells::tourCells);OrderListAppearance.apply(database,user,result);} + return new OrderResult(result,total==null?0:total,limit,layout==null?List.of():layout.columns(),layout==null?"":layout.warning(),layout==null?Set.of():OrderListCells.actions(user),layout==null?null:layout.sorting(),layout==null?null:OrderListTotals.read(source,layout)); } public DeliveryOrder order(LegacyUser user,long id) { user.require(4); diff --git a/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java b/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java new file mode 100644 index 0000000..af24c0a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LegacySessionRightsFilter.java @@ -0,0 +1,43 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.stadtbote.votian.database.DatabaseService; +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.dao.DataAccessException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Revoke rights on existing sessions without mutating a principal shared by concurrent tabs. */ +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 { + 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;} + try { + var rows=db.queryForList(u.transferAccount()?TransferPortalScope.ACCOUNT_SQL:u.customerAccount()?CustomerPortalScope.ACCOUNT_SQL:u.courierAccount()?CourierPortalScope.ACCOUNT_SQL:u.warehouseAccount()?WarehousePortalScope.ACCOUNT_SQL:SQL,u.userId(),u.employeeId(),u.originalHeadquartersId()); + if(rows.size()!=1||!(auth.getDetails() instanceof LegacyCredentialState stamp)||!stamp.fingerprint().equals(text(rows.getFirst(),"credential_stamp"))){if(req.getSession(false)!=null)req.getSession(false).invalidate();SecurityContextHolder.clearContext();res.setStatus(401);res.setContentType("application/json;charset=UTF-8");json.writeValue(res.getOutputStream(),ApiErrors.body("Der Zugang wurde geändert. Bitte erneut anmelden."));return;} + if(u.warehouseAccount()&&(!WarehousePortalScope.allowed(req.getMethod(),path)||req.getHeader("X-Workspace-Headquarters")!=null||req.getHeader("X-Order-Headquarters")!=null)){res.setStatus(403);res.setContentType("application/json;charset=UTF-8");json.writeValue(res.getOutputStream(),ApiErrors.body("Dieser Zugang ist auf den Lagerarbeitsplatz beschränkt."));return;} + if(u.courierAccount()&&(!CourierPortalScope.allowed(req.getMethod(),path)||req.getHeader("X-Workspace-Headquarters")!=null||req.getHeader("X-Order-Headquarters")!=null)){res.setStatus(403);res.setContentType("application/json;charset=UTF-8");json.writeValue(res.getOutputStream(),ApiErrors.body("Dieser Zugang ist auf das Transporteurportal beschränkt."));return;} + if(u.customerAccount()&&(!CustomerPortalScope.allowed(req.getMethod(),path)||req.getHeader("X-Workspace-Headquarters")!=null||req.getHeader("X-Order-Headquarters")!=null)){res.setStatus(403);res.setContentType("application/json;charset=UTF-8");json.writeValue(res.getOutputStream(),ApiErrors.body("Dieser Zugang ist auf das Kundenportal beschränkt."));return;} + if(u.transferAccount()){ + try{TransferPortalScope.verify(db,u);}catch(org.springframework.security.access.AccessDeniedException|org.springframework.web.server.ResponseStatusException failure){res.setStatus(403);res.setContentType("application/json;charset=UTF-8");json.writeValue(res.getOutputStream(),ApiErrors.body("Der Datentransfer-Zugang wurde geändert. Bitte erneut anmelden."));return;} + if(!TransferPortalScope.allowed(req.getMethod(),path)||req.getHeader("X-Workspace-Headquarters")!=null||req.getHeader("X-Order-Headquarters")!=null){res.setStatus(403);res.setContentType("application/json;charset=UTF-8");json.writeValue(res.getOutputStream(),ApiErrors.body("Dieser Zugang ist auf die zugeordnete Dokumentablage beschränkt."));return;} + chain.doFilter(req,res);return; + } + String live=text(rows.getFirst(),"emp_rights");var effective=new StringBuilder(); + // New grants require a fresh login; revocations take effect on the next request. + for(int i=0;i=8){bits-=8;bytes.write((buffer>>bits)&255);}} + try{var mac=Mac.getInstance("HmacSHA1");mac.init(new SecretKeySpec(bytes.toByteArray(),"HmacSHA1"));byte[] hash=mac.doFinal(ByteBuffer.allocate(8).putLong(epochSeconds/30).array());int offset=hash[hash.length-1]&15;int value=ByteBuffer.wrap(hash,offset,4).getInt()&0x7fffffff;return String.format(Locale.ROOT,"%06d",value%1_000_000);}catch(java.security.GeneralSecurityException e){throw new IllegalStateException(e);} + } + static boolean matches(String secret,String supplied,long seconds){ + if(supplied==null||!supplied.matches("[0-9]{6}"))return false; + try{return MessageDigest.isEqual(code(secret,seconds).getBytes(StandardCharsets.US_ASCII),supplied.getBytes(StandardCharsets.US_ASCII));}catch(IllegalArgumentException e){return false;} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LegacyUser.java b/backend/src/main/java/de/stadtbote/votian/LegacyUser.java index dcf826b..f8295f6 100644 --- a/backend/src/main/java/de/stadtbote/votian/LegacyUser.java +++ b/backend/src/main/java/de/stadtbote/votian/LegacyUser.java @@ -5,12 +5,18 @@ import java.security.Principal; import static de.stadtbote.votian.Models.*; /** Entry scope can be selected explicitly; the original account headquarters remains immutable. */ -public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights, long originalHeadquartersId) implements Principal { +public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights, long originalHeadquartersId, int accountType) implements Principal { + public LegacyUser(long userId,long employeeId,long headquartersId,String username,String rights,long originalHeadquartersId) {this(userId,employeeId,headquartersId,username,rights,originalHeadquartersId,1);} public LegacyUser(long userId,long employeeId,long headquartersId,String username,String rights) {this(userId,employeeId,headquartersId,username,rights,headquartersId);} + public boolean transferAccount(){return accountType>=5;} + public boolean warehouseAccount(){return accountType==4;} + public boolean customerAccount(){return accountType==2;} + public boolean customerRight(int position){return customerAccount()&&position>=0&&position<16&&rights!=null&&rights.length()>position&&rights.charAt(position)=='1';} + public boolean courierAccount(){return accountType==3;} @Override public String getName() { return username; } - public boolean hasRight(int position) { return rights != null && rights.length() > position && rights.charAt(position) == '1'; } + public boolean hasRight(int position) { return warehouseAccount()?position==14:accountType==1&&rights != null && rights.length() > position && rights.charAt(position) == '1'; } public void require(int position) { if (!hasRight(position)) throw new AccessDeniedException("Für diesen Bereich fehlt die Berechtigung im vorhandenen VoTiAn-Konto."); } - public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),hasRight(5),hasRight(4)&&hasRight(7),hasRight(0),hasRight(1),hasRight(5)&&hasRight(7),hasRight(4)&&hasRight(7),hasRight(4) && hasRight(5),hasRight(7),hasRight(8),hasRight(0)&&hasRight(11),hasRight(4)||hasRight(18)||(hasRight(0)&&hasRight(17)),hasRight(15)); } + public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),hasRight(5),hasRight(4)&&hasRight(7),hasRight(0),hasRight(1),hasRight(5)&&hasRight(7),hasRight(4)&&hasRight(7),hasRight(4) && hasRight(5),hasRight(7),hasRight(8),hasRight(0)&&hasRight(11),hasRight(4)||hasRight(18)||(hasRight(0)&&hasRight(17)),hasRight(15),hasRight(22)||hasRight(23)||hasRight(6)); } } diff --git a/backend/src/main/java/de/stadtbote/votian/LoginActivity.java b/backend/src/main/java/de/stadtbote/votian/LoginActivity.java new file mode 100644 index 0000000..7843169 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LoginActivity.java @@ -0,0 +1,38 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Service; +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; + +/** Durable employee login history and the legacy disposition reset, after successful authentication. */ +@Service +class LoginActivity { + final DatabaseService db;final BusinessSupport support; + LoginActivity(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + void record(LegacyUser u){if(u.accountType()!=1)return;record(u,UUID.randomUUID().toString(),LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));} + void record(LegacyUser u,String token,LocalDateTime now){ + HeaderActivity.access(u);BusinessSupport.token(token);if(u.headquartersId()!=u.originalHeadquartersId())throw new org.springframework.security.access.AccessDeniedException("Ungültige Anmeldeniederlassung.");support.transactional("genericdatacontainer"); + db.employeeActivityWriteTransaction(()->{ + one(db.queryForList("SELECT employee.emp_id,user.hq_id FROM employee JOIN user ON user.usr_id=employee.usr_id WHERE employee.emp_id=? AND user.usr_id=? AND user.hq_id=? AND user.usr_type=1 AND employee.csc_id=0",u.employeeId(),u.userId(),u.headquartersId())); + String description="LOGIN_VOTIAN | NG="+token; + var event=new BusinessSupport.Audit(u.headquartersId(),u.userId(),0,0,0,u.employeeId(),52,description,now.toString()); + var prior=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngaudit' AND gdc_gen_fieldname=?",token); + if(!prior.isEmpty()){if(prior.size()!=1||!text(prior.getFirst(),"gdc_content").equals(support.encode(event)))throw bad("Der Anmeldevorgang ist nicht eindeutig.");return null;} + db.update("UPDATE genericdatacontainer SET gdc_content='0',gdc_context='' WHERE gdc_obj_type='usr' AND gdc_obj_id=? AND gdc_gen_fieldname='usr_state_dispo'",u.userId()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngaudit","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",token,"gdc_content",support.encode(event),"gdc_context","PENDING")); + return null; + }); + } + 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 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,'{}')"; + var rows=db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngaudit' AND gdc_context='PENDING' AND JSON_UNQUOTE(JSON_EXTRACT("+value+",'$.user'))=? AND JSON_UNQUOTE(JSON_EXTRACT("+value+",'$.operation')) IN ('52','53') AND CAST(JSON_UNQUOTE(JSON_EXTRACT("+value+",'$.at')) AS DATETIME)>=? AND CAST(JSON_UNQUOTE(JSON_EXTRACT("+value+",'$.at')) AS DATETIME)<=? ORDER BY CAST(JSON_UNQUOTE(JSON_EXTRACT("+value+",'$.at')) AS DATETIME) DESC,gdc_id DESC LIMIT 2",Long.toString(u.userId()),from,to); + var pending=new ArrayList();for(var r:rows){try{var e=support.json.readValue(text(r,"gdc_content"),BusinessSupport.Audit.class);pending.add(new Event("ng:"+text(r,"gdc_gen_fieldname"),LocalDateTime.parse(e.at())));}catch(Exception e){throw new org.springframework.dao.DataRetrievalFailureException("Die Anmeldungshistorie konnte nicht gelesen werden.");}} + return merge(legacy,pending); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/LonghaulCandidates.java b/backend/src/main/java/de/stadtbote/votian/LonghaulCandidates.java new file mode 100644 index 0000000..b8ce2ad --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LonghaulCandidates.java @@ -0,0 +1,125 @@ +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.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Read-only candidate discovery; selecting a candidate still requires an assignment preview. */ +@RestController +@RequestMapping("/api/orders/{job}/courier-assignment/candidates") +public class LonghaulCandidates { + final DatabaseService db;final OrderOperations orders;final OrderEntryCatalog catalog;final OrderRoutingService routing; + public LonghaulCandidates(DatabaseService db,OrderOperations orders,OrderEntryCatalog catalog,OrderRoutingService routing){this.db=db;this.orders=orders;this.catalog=catalog;this.routing=routing;} + public enum Mode { CURRENT, RESERVED } + public record Query(Mode mode,double radius) {} + record Preference(boolean favourite,boolean filterRequired,boolean measuresRequired) {} + record Rules(Map values,Set favourites,Set blocked,List> filters) {} + record Pool(OrderOperations.Snapshot order,List> vehicles,List> windows,Rules rules,Set previous) {} + record Distances(Double current,Double home,Double desired,Double availability) {} + record Candidate(Map vehicle,Preference preference,List blockers,List warnings,Distances distance,boolean availabilityValid,boolean desiredValid) {} + static final List SETTINGS=List.of(AssignmentVehicles.SETTING,"RANKING_FAVOURED_COURIER_FOR_STATION","RANKING_FAVOURED_COURIER_FOR_PAYER","RANKING_FAVOURED_COURIER_FILTER","RANKING_FAVOURED_COURIER_CRVH","RANKING_BLOCKED_COURIER_FOR_STATION","RANKING_BLOCKED_COURIER_FOR_PAYER","RANKING_JB2CRVH_MEASURE","RANKING_RADIUS_KM","MASK_COURIER_SORT_BY_OCCUPIED","CSC_ID_PAYER_CASH"); + @GetMapping("/options") public Object options(Authentication auth,@PathVariable long job){return options(ApiController.user(auth),job);} + @PostMapping public Object search(Authentication auth,@PathVariable long job,@RequestBody Query query){return search(ApiController.user(auth),job,query);} + Map options(LegacyUser u,long job){OrderCourierAssignment.access(u);return db.readTransaction(()->{ + var s=orders.snapshot(u,job,false);requireLonghaul(s);double step=step(setting(catalog,u,"RANKING_RADIUS_KM")); + return fields("mode",id(s.job(),"jb_reserv")>0?Mode.RESERVED:Mode.CURRENT,"radiusStep",step,"maximumRadius",maximum(step)); + });} + static void requireLonghaul(OrderOperations.Snapshot s){if(id(s.job(),"jb_longhaul")!=1)throw bad("Die Radiusauswahl ist für BWV-Aufträge vorgesehen.");} + static String setting(OrderEntryCatalog catalog,LegacyUser u,String key){String v=catalog.parameter(u.headquartersId(),key);return v.isBlank()?catalog.parameter(0,key):v;} + static double step(String raw){if(raw.isBlank())return 50;try{double value=Double.parseDouble(raw);if(Double.isFinite(value)&&value>0&&value<=960)return value;}catch(NumberFormatException ignored){}throw bad("RANKING_RADIUS_KM muss größer als 0 und höchstens 960 sein.");} + static double maximum(double step){return Math.ceil(960/step)*step;} + static Set relationIds(DatabaseService db,OrderOperations.Snapshot s,Map settings,String prefix,int relation){ + var centers=new TreeSet(); + if("1".equals(settings.get(prefix+"_FOR_STATION")))s.stops().forEach(t->centers.add(id(t,"csc_id"))); + if("1".equals(settings.get(prefix+"_FOR_PAYER"))){ + long payer=id(s.job(),"csc_id_payer"); + if(payer==-1||Long.toString(payer).equals(settings.get("CSC_ID_PAYER_CASH")))centers.add(id(s.job(),"csc_id_payer_cash")); + else {s.prices().forEach(p->centers.add(id(p,"csc_id")));centers.add(payer);} + } + centers.removeIf(c->c<=0);if(centers.isEmpty())return Set.of(); + var args=new ArrayList(centers);args.add(relation); + var rows=db.queryForList("SELECT DISTINCT r.cr_id FROM customercourier r JOIN costcenter c ON c.cs_id=r.cs_id WHERE c.csc_id IN ("+String.join(",",Collections.nCopies(centers.size(),"?"))+") AND r.cscr_relation=? ORDER BY r.cr_id",args.toArray()); + var result=new TreeSet();rows.forEach(r->result.add(id(r,"cr_id")));return result; + } + static Rules rules(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s){ + var values=new TreeMap();SETTINGS.forEach(k->values.put(k,setting(catalog,u,k))); + return new Rules(values,relationIds(db,s,values,"RANKING_FAVOURED_COURIER",1),relationIds(db,s,values,"RANKING_BLOCKED_COURIER",0),db.queryForList("SELECT crf_short,crf_type FROM courierfilter WHERE crf_type IN (0,1) ORDER BY crf_short,crf_type")); + } + static Preference preference(Rules rules,long courier){boolean fav=rules.favourites().contains(courier);return new Preference(fav,!fav||"1".equals(rules.values().get("RANKING_FAVOURED_COURIER_FILTER")),!fav||"1".equals(rules.values().get("RANKING_FAVOURED_COURIER_CRVH")));} + static Preference preference(DatabaseService db,OrderEntryCatalog catalog,LegacyUser u,OrderOperations.Snapshot s,long courier,List evidence){var rules=rules(db,catalog,u,s);evidence.add(rules);return preference(rules,courier);} + Pool pool(LegacyUser u,long job){ + var s=orders.snapshot(u,job,false);requireLonghaul(s); + if(s.stops().size()<2)throw bad("Eine BWV-Auswahl benötigt mindestens zwei vollständige Stationen."); + // Like the PHP BWV query this is nationwide, but general order/stammdaten access stays HQ-scoped. + var vehicles=db.queryForList("SELECT cv.*,"+AssignmentVehicles.IDENTITY+",cr.usr_id,cr.hq_id,cr.vht_id AS ranking_vehicle_type,cr.cr_available,cr.cr_occupied,cr.cr_availabletime,cr.cr_locationzipcode,cr.cr_outlay,cr.cr_filter,cr.cr_gps_lat,cr.cr_gps_long,cr.cr_gps_lat_home,cr.cr_gps_long_home,cr.cr_gps_lat_reserv,cr.cr_gps_long_reserv,cr.cr_gps_lat_desired,cr.cr_gps_long_desired,cmp.cmp_comp,cmp.cmp_no_longhaul,a.ad_zipcode AS home_postal,h.hq_mnemonic,mt.mt_value AS vehicle_type "+AssignmentVehicles.joins(AssignmentVehicles.from(true,AssignmentVehicles.multiple(catalog,u)),"LEFT JOIN address a ON a.ad_id=cmp.ad_id JOIN headquarters h ON h.hq_id=cr.hq_id JOIN metatype mt ON mt.mt_type='vehicletype' AND mt.mt_sort=cv.vht_id")+" AND cr.cr_sid=cv.crvh_sid ORDER BY cv.crvh_sid,cv.crvh_id"); + vehicles=AssignmentVehicles.identities(vehicles); + var assignments=ClassicCandidates.assignments(db,s); + vehicles=vehicles.stream().map(v->{var row=new LinkedHashMap(v);var work=assignments.getOrDefault(text(v,"crvh_sid"),Map.of());row.put("reported_occupied",id(v,"cr_occupied"));row.put("cr_occupied",id(work,"occupied"));row.put("active_orders",id(work,"active_count"));return (Map)row;}).toList(); + var windows=db.queryForList("SELECT gdc_obj_id,gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='bwvL' AND gdc_gen_fieldname IN ('availabilityStart','bwvLocationPreference') ORDER BY gdc_obj_id,gdc_gen_fieldname,gdc_id"); + var previous=new TreeSet();db.queryForList("SELECT cr_id FROM autoranking WHERE jb_id=? ORDER BY cr_id",job).forEach(r->previous.add(id(r,"cr_id"))); + return new Pool(s,vehicles,windows,rules(db,catalog,u,s),previous); + } + Map search(LegacyUser u,long job,Query query){ + OrderCourierAssignment.access(u);if(query==null||query.mode()==null||!Double.isFinite(query.radius())||query.radius()<0)throw bad("Bitte einen gültigen Suchmodus und Radius wählen."); + var before=db.readTransaction(()->pool(u,job));double step=step(before.rules().values().get("RANKING_RADIUS_KM")),max=maximum(step); + if(query.radius()>max)throw bad("Der Suchradius darf höchstens "+max+" km betragen."); + var first=routing.geocode(LonghaulRemuneration.station(before.order().stops().getFirst()));var last=routing.geocode(LonghaulRemuneration.station(before.order().stops().getLast())); + var at=dateTime(before.order().job(),"jb_ordertime");if(at==null)throw bad("Dem Auftrag fehlt die Auftragszeit."); + var candidates=new ArrayList();int unresolvedHomes=0; + for(var v:before.vehicles()){ + var preference=preference(before.rules(),id(v,"cr_id"));var blocks=new ArrayList();var warnings=new ArrayList(); + if(AssignmentVehicles.ambiguous(v,"1".equals(before.rules().values().get(AssignmentVehicles.SETTING))))blocks.add("Mehrere Fahrer sind auf diesem Fahrzeug angemeldet"); + if(id(v,"cr_available")!=1)blocks.add("Nicht verfügbar gemeldet"); + if(id(v,"cmp_no_longhaul")!=0)blocks.add("Bundesweite Vermittlung gesperrt"); + if(id(v,"ranking_vehicle_type")id(v,pair[1]))("1".equals(before.rules().values().get("RANKING_JB2CRVH_MEASURE"))&&preference.measuresRequired()?blocks:warnings).add(pair[2]+" nicht ausreichend"); + for(String needed:FleetAssignment.tokens(text(before.order().job(),"jb_cr_filter"))){ + var types=before.rules().filters().stream().filter(f->text(f,"crf_short").equals(needed)).map(f->id(f,"crf_type")).distinct().toList(); + if(types.stream().anyMatch(t->!FleetAssignment.tokens(text(v,t==0?"crvh_filter":"cr_filter")).contains(needed)))(preference.filterRequired()?blocks:warnings).add("Filterbedingung nicht erfüllt: "+needed); + } + var home=point(v,"home"); + if(home==null&&!text(v,"home_postal").isBlank())try{home=routing.homePostalCenter(text(v,"home_postal"));}catch(RuntimeException e){warnings.add("Heimatort konnte nicht aufgelöst werden");} + var distance=new Distances(distance(point(v,""),first),distance(home,last),distance(point(v,"desired"),query.mode()==Mode.CURRENT?last:first),distance(point(v,"reserv"),first)); + boolean available=window(before.windows(),id(v,"usr_id"),"availabilityStart",at),desired=window(before.windows(),id(v,"usr_id"),"bwvLocationPreference",at); + if(query.mode()==Mode.RESERVED&&!available&&!desired)blocks.add("Kein passendes Verfügbarkeits- oder Wunschzeitfenster"); + if(distance.current()==null&&query.mode()==Mode.CURRENT)blocks.add("Aktuelle Standortkoordinaten fehlen"); + if(home==null){warnings.add("Heimatkoordinaten fehlen; die Vergütungsprüfung kann fehlschlagen");if(id(v,"cr_available")==1)unresolvedHomes++;} + candidates.add(new Candidate(v,preference,List.copyOf(blocks),List.copyOf(warnings),distance,available,desired)); + } + double radius=query.radius()==0?step:query.radius(); + while(query.radius()==0&&radiusincluded(c,query.mode(),r)&&c.blockers().isEmpty()))break;radius=Math.min(max,radius+step);} + double used=radius; + var selected=candidates.stream().filter(c->included(c,query.mode(),used)||before.rules().blocked().contains(id(c.vehicle(),"cr_id"))).sorted(ranking("1".equals(before.rules().values().get("MASK_COURIER_SORT_BY_OCCUPIED")))).toList(); + db.readTransaction(()->{unchanged(pool(u,job),revision(before));return null;}); + return fields("mode",query.mode(),"radius",used,"radiusStep",step,"maximumRadius",max,"automatic",query.radius()==0,"totalAvailable",candidates.size(),"warnings",unresolvedHomes==0?List.of():List.of("Bei "+unresolvedHomes+" verfügbaren Fahrzeugen fehlt ein auflösbarer Heimatort. Treffer über den Heimatort können deshalb fehlen."),"matched",selected.size(),"orderRevision",revision(before.order()),"items",selected.stream().map(c->view(c,query.mode(),used,before.previous())).toList()); + } + static Comparator ranking(boolean byOccupied){ + // PHP splits free/occupied first, then favourites within each group. Blocked rows stay last. + return Comparator.comparingInt(c->c.blockers().isEmpty()?0:1) + .thenComparingInt(c->byOccupied&&id(c.vehicle(),"cr_occupied")!=0?1:0) + .thenComparingInt(c->c.preference().favourite()?0:1) + .thenComparing(c->text(c.vehicle(),"cr_locationzipcode")) + .thenComparing(c->text(c.vehicle(),"cr_availabletime")) + .thenComparing(c->text(c.vehicle(),"crvh_sid")); + } + static boolean window(List> windows,long user,String key,LocalDateTime at){var found=windows.stream().filter(w->id(w,"gdc_obj_id")==user&&text(w,"gdc_gen_fieldname").equals(key)).toList();return found.size()==1&&LonghaulRemuneration.within(text(found.getFirst(),"gdc_content"),at);} + static OrderRoutingService.Point point(Map row,String suffix){String extra=suffix.isEmpty()?"":"_"+suffix;try{return LonghaulRemuneration.point(row,"cr_gps_lat"+extra,"cr_gps_long"+extra,"Suchstandort");}catch(RuntimeException e){return null;}} + /** Exact PHP search metric. Road distances are separately calculated in the price preview. */ + static Double distance(OrderRoutingService.Point a,OrderRoutingService.Point b){return a==null||b==null?null:Math.hypot(a.latitude()-b.latitude(),a.longitude()-b.longitude())*111;} + static boolean near(Double distance,double radius){return distance!=null&&distance view(Candidate c,Mode mode,double radius,Set previous){ + var v=c.vehicle();var reasons=new ArrayList();if(c.preference().favourite())reasons.add("Kundenfavorit"); + if(mode==Mode.CURRENT){if(near(c.distance().current(),radius))reasons.add("Standort bei Abholung");if(near(c.distance().home(),radius))reasons.add("Heimatort beim Ziel");if(near(c.distance().desired(),radius))reasons.add("Wunschort beim Ziel");} + else {if(c.availabilityValid()&&near(c.distance().availability(),radius))reasons.add("Verfügbarkeit bei Abholung");if(c.desiredValid()&&near(c.distance().desired(),radius))reasons.add("Wunschort bei Abholung");} + LonghaulRemuneration.Start start=mode==Mode.CURRENT?LonghaulRemuneration.Start.CURRENT:c.availabilityValid()&&(near(c.distance().availability(),radius)||c.preference().favourite())?LonghaulRemuneration.Start.AVAILABILITY:LonghaulRemuneration.Start.DESIRED; + return fields("id",id(v,"crvh_id"),"courierId",id(v,"cr_id"),"ownerCourierId",id(v,"owner_courier_id"),"driverName",text(v,"driver_name"),"sid",text(v,"crvh_sid"),"plate",text(v,"crvh_vh_sign"),"name",text(v,"cmp_comp"),"headquarters",text(v,"hq_mnemonic"),"vehicleType",id(v,"vht_id"),"vehicleTypeName",text(v,"vehicle_type"),"online",true,"available",id(v,"cr_available")==1,"occupied",id(v,"cr_occupied")!=0,"reportedOccupied",id(v,"reported_occupied")!=0,"activeOrders",id(v,"active_orders"),"postalCode",text(v,"cr_locationzipcode"),"homePostalCode",text(v,"home_postal"),"availableTime",text(v,"cr_availabletime"),"favourite",c.preference().favourite(),"previouslyOffered",previous.contains(id(v,"cr_id")),"reasons",reasons,"warnings",c.warnings(),"blockers",c.blockers(),"distance",c.distance(),"startMode",start); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LonghaulRemuneration.java b/backend/src/main/java/de/stadtbote/votian/LonghaulRemuneration.java new file mode 100644 index 0000000..1903d5c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LonghaulRemuneration.java @@ -0,0 +1,127 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Service; +import java.math.*; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.LegacyOrderPricing.money; + +/** Fresh PHP: get_cr_price_longhaul + jb_list/setCourier; no temporary price writes on preview. */ +@Service +public class LonghaulRemuneration { + final DatabaseService db;final OrderEntryCatalog catalog;final LegacyOrderPricing pricing;final OrderRoutingService routing; + public LonghaulRemuneration(DatabaseService db,OrderEntryCatalog catalog,LegacyOrderPricing pricing,OrderRoutingService routing){this.db=db;this.catalog=catalog;this.pricing=pricing;this.routing=routing;} + public enum Start { CURRENT, AVAILABILITY, DESIRED } + record Rates(BigDecimal kilometer,BigDecimal roundMarkup,BigDecimal pzmMarkup,BigDecimal pzmBase,BigDecimal service,boolean retour){} + record Context(long job,long vehicle,Start startMode,Map courier,List> active,List> windows,List> stations,Map startStation,LocalDate day,LocalDateTime orderTime,String startLabel,String homePostalCode,Rates rates,BigDecimal jobKm,OrderRoutingService.Point origin,OrderRoutingService.Point home){} + record Route(BigDecimal approachKm,long approachMinutes,BigDecimal roundKm,BigDecimal endingKm){} + record Amounts(String method,BigDecimal roundBase,BigDecimal roundTotal,BigDecimal pzmBase,BigDecimal pzmTotal,BigDecimal selectedBase,BigDecimal selectedTotal,BigDecimal selectedMarkup,BigDecimal oneWayKm,BigDecimal payableBase,BigDecimal payableTotal,BigDecimal totalWithService){} + record Prepared(Context context,Route route,Amounts amounts){} + + static Start start(Start value){return value==null?Start.CURRENT:value;} + Context context(LegacyUser u,OrderOperations.Snapshot s,long vehicle,Start start,boolean lock){return context(u,s,vehicle,start,lock,0);} + Context context(LegacyUser u,OrderOperations.Snapshot s,long vehicle,Start start,boolean lock,long expectedCourier){ + if(id(s.job(),"jb_longhaul")!=1)return null; + var selected=AssignmentVehicles.resolve(db,catalog,u,vehicle,expectedCourier,true,lock); + start=start(start);long job=id(s.job(),"jb_id"); + var at=dateTime(s.job(),"jb_ordertime");if(at==null)throw bad("Dem Fernauftrag fehlt das Auftragsdatum."); + var day=LocalDate.parse(db.queryForObject("SELECT DATE_FORMAT(CURRENT_DATE,'%Y-%m-%d')",String.class)); + String sql="SELECT cr.cr_id,cr.usr_id,cr.cr_gps_lat,cr.cr_gps_long,cr.cr_gps_lat_home,cr.cr_gps_long_home,cr.cr_gps_lat_reserv,cr.cr_gps_long_reserv,cr.cr_gps_lat_desired,cr.cr_gps_long_desired,cr.cr_locationzipcode,cr.cr_available,cv.crvh_sid,cmp.cmp_no_longhaul,a.ad_zipcode AS home_postal "+AssignmentVehicles.joins(AssignmentVehicles.from(true,AssignmentVehicles.multiple(catalog,u)), "LEFT JOIN address a ON a.ad_id=cmp.ad_id")+" AND cv.crvh_id=? AND cr.cr_id=?"; + var rows=lock?db.lockRows(sql,vehicle,id(selected,"cr_id")):db.queryForList(sql,vehicle,id(selected,"cr_id"));if(rows.size()!=1)throw bad("Das BWV-Fahrzeug ist nicht mehr verfügbar.");var cr=rows.getFirst(); + if(id(cr,"cmp_no_longhaul")==1)throw bad("Für diesen Transporteur ist die bundesweite Vermittlung gesperrt."); + long payer=OrderRecalculation.payerId(s);var customer=catalog.billingCenter(u,payer,false);long cs=id(customer,"cs_id"),hq=id(s.job(),"hq_id_dispo");if(hq==0)hq=u.headquartersId(); + String vht=text(one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(s.job(),"vht_id"))),"mt_value"); + var tariff=pricing.longhaulTariff(hq,cs,vht,at); + if(!tariff.found())throw bad("Für die bestellte Fahrzeugklasse fehlt ein gültiger Rund-km-Preis."); + BigDecimal rate=tariff.courier().signum()!=0?tariff.courier():money(tariff.price().multiply(BigDecimal.ONE.subtract(pricing.courierDeduction(hq,cs,vht,at).movePointLeft(2)))); + if(rate.signum()<=0)throw bad("Für die bestellte Fahrzeugklasse fehlt ein positiver Rund-km-Vergütungssatz."); + var basis=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cr_subtotal_last_without_service' ORDER BY gdc_id",job); + if(basis.size()!=1)throw bad("Die PZM-Vergütungsbasis fehlt oder ist mehrdeutig. Bitte den Fernauftrag zuerst neu kalkulieren."); + BigDecimal baseline=number(text(basis.getFirst(),"gdc_content"),"PZM-Vergütungsbasis",false); + BigDecimal service=OrderRecalculation.detail(s,3);if(service==null)service=BigDecimal.ZERO;if(service.signum()<0)throw bad("Negative Kurier-Servicepreise benötigen eine gesonderte Korrektur."); + var rates=new Rates(rate,number(catalog.parameter(0,"BWV_TSZ"),"BWV-Treibstoffzuschlag",true),pricing.fuelCourierMarkup(hq,cs,vht,at,customer),baseline,service,catalog.enabled(0,"BWV_CR_PRICE_RETOUR")); + BigDecimal jobKm=OrderRecalculation.detail(s,11);if(jobKm==null||jobKm.signum()<=0)throw bad("Für den Fernauftrag fehlen gültige Lastkilometer. Bitte zuerst neu kalkulieren."); + if(s.stops().size()<2)throw bad("Eine BWV-Vermittlung benötigt mindestens zwei vollständige Stationen."); + // Same occupied-order lead-time condition as checkCrIsOccupied; HQ mapping is explicit. + var active=db.queryForList(""" + SELECT DISTINCT j.jb_id,j.hq_id,j.jb_ordertime,j.jb_status,j.jb_version FROM job j + JOIN tour t ON t.jb_id=j.jb_id AND t.tr_sort=1 JOIN address a ON a.ad_id=t.ad_id + LEFT JOIN serviceplz p ON p.srvp_plz=a.ad_zipcode + LEFT JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id AND lt.hq_id=j.hq_id + WHERE j.jb_id<>? AND (j.cr_id=? OR j.cr_id_order=?) AND j.jb_status IN (0,1) AND COALESCE(j.jb_storno,0)=0 + AND DATE(j.jb_ordertime)=CURRENT_DATE + AND (DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(lt.srvpt_traveltime,30) MINUTE)<=NOW() + OR (DATE_SUB(j.jb_ordertime,INTERVAL GREATEST(lt.srvpt_traveltime,30) MINUTE)<=? AND j.jb_ordertime>=?)) + ORDER BY j.jb_ordertime,j.jb_id + """,job,id(cr,"cr_id"),id(cr,"cr_id"),at,at); + if(active.size()>1)throw bad("Der Kurier hat mehrere gleichzeitig belegende Aufträge. Bitte deren Reihenfolge zuerst klären."); + Map startStation=Map.of();String label;OrderRoutingService.Point origin=null;List> windows=List.of(); + if(!active.isEmpty()){ + var current=active.getFirst(); + startStation=one(db.queryForList("SELECT t.tr_id,t.tr_sort,t.tr_hsno,a.* FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort DESC,t.tr_id DESC LIMIT 1",id(current,"jb_id"))); + label="Ziel von Auftrag #"+id(current,"jb_id")+" · "+text(startStation,"ad_zipcode")+" "+text(startStation,"ad_city"); + }else if(start==Start.CURRENT){origin=point(cr,"cr_gps_lat","cr_gps_long","aktuellen Kurierstandort");label="Aktueller Standort · "+text(cr,"cr_locationzipcode");} + else { + String key=start==Start.AVAILABILITY?"availabilityStart":"bwvLocationPreference"; + windows=db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='bwvL' AND gdc_obj_id=? AND gdc_gen_fieldname=? ORDER BY gdc_id",id(cr,"usr_id"),key); + if(windows.size()!=1||!within(text(windows.getFirst(),"gdc_content"),at))throw bad("Für den gewählten BWV-Startort fehlt ein passendes Zeitfenster zum Auftragsdatum."); + String suffix=start==Start.AVAILABILITY?"reserv":"desired";origin=point(cr,"cr_gps_lat_"+suffix,"cr_gps_long_"+suffix,"gewählten BWV-Startort");label=(start==Start.AVAILABILITY?"Verfügbarkeitsbeginn · ":"BWV-Wunschort · ")+windowPostal(text(windows.getFirst(),"gdc_content")); + } + var homeLat=decimal(cr,"cr_gps_lat_home");var homeLon=decimal(cr,"cr_gps_long_home"); + var home=homeLat==null||homeLon==null||homeLat.signum()==0&&homeLon.signum()==0?null:point(cr,"cr_gps_lat_home","cr_gps_long_home","Heimatort"); + return new Context(job,vehicle,start,cr,active,windows,s.stops(),startStation,day,at,label,text(cr,"home_postal"),rates,jobKm,origin,home); + } + Prepared prepare(Context c){if(c==null)return null; + var points=c.stations().stream().map(LonghaulRemuneration::station).map(routing::geocode).toList(); + var origin=c.origin()==null?routing.geocode(station(c.startStation())):c.origin();var approach=routing.drivingLeg(origin,points.getFirst()); + BigDecimal meters=approach.meters();for(int i=1;i3?parts[3]:"";} + static boolean within(String value,LocalDateTime time){try{ + var p=value.split("\\|",-1);if(p.length<3)return false;var format=DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT); + var from=LocalDateTime.parse(p[1],format).atOffset(ZoneOffset.UTC).toInstant();var to=LocalDateTime.parse(p[2],format).atOffset(ZoneOffset.UTC).toInstant(); + var candidate=time.atZone(ZoneId.of("Europe/Berlin")).toInstant();return !from.isAfter(to)&&!candidate.isBefore(from)&&!candidate.isAfter(to); + }catch(DateTimeException e){return false;}} + static OrderRoutingService.Point point(Map row,String lat,String lon,String label){ + var a=decimal(row,lat);var b=decimal(row,lon);if(a==null||b==null||a.signum()==0&&b.signum()==0||a.abs().compareTo(BigDecimal.valueOf(90))>0||b.abs().compareTo(BigDecimal.valueOf(180))>0)throw bad("Für den "+label+" fehlen gültige Koordinaten.");return new OrderRoutingService.Point(a.doubleValue(),b.doubleValue()); + } + static OrderEntryModels.Station station(Map t){return new OrderEntryModels.Station(null,Models.StopType.BOTH,"",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());} + static BigDecimal number(String s,String label,boolean blank){try{if(blank&&s.isBlank())return BigDecimal.ZERO;var n=new BigDecimal(s.trim().replace(',','.'));if(n.precision()>20||Math.abs(n.scale())>10)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad(label+" ist ungültig.");}} + static void storeBasis(DatabaseService db,long job,OrderEntryModels.LonghaulBasis basis){if(basis==null)return; + metadata(db,job,"jb_cr_subtotal_last_without_service",basis.freight().toPlainString()); + // Service is paid once when the courier is assigned, outside the fuel-markup base. + db.update("UPDATE jobprice SET jbprc_price=? WHERE jb_id=? AND mt_sort=3",basis.service(),job); + } + static void metadata(DatabaseService db,long job,String name,String value){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,name);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname",name,"gdc_content",value,"gdc_context",""));} + String apply(Prepared p,OrderOperations.Snapshot snapshot,Map changes){ + if(p==null)return "";long job=p.context().job();var a=p.amounts(); + changes.putAll(fields("jb_cr_price",a.totalWithService(),"jb_cr_subprice",a.payableBase(),"jb_freetext_3",replaceDescription(text(snapshot.job(),"jb_freetext_3"),description(p)))); + db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort IN (1,9,10)",job); + BusinessSupport.insertWithoutKey(db,"jobprice",fields("jb_id",job,"mt_sort",10,"jbprc_price",a.payableBase().add(p.context().rates().service()),"jbprc_remark","")); + BusinessSupport.insertWithoutKey(db,"jobprice",fields("jb_id",job,"mt_sort",a.method().equals("ROUND")?9:1,"jbprc_price",a.selectedMarkup(),"jbprc_remark","")); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cr_price_retour'",job); + if(p.context().rates().retour())metadata(db,job,"jb_cr_price_retour",a.selectedTotal().toPlainString()+"|"+a.selectedBase().toPlainString()); + metadata(db,job,"ng_bwv_assignment",new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()).encode(p)); + return "BWV "+a.method()+" · Frachtvergütung "+a.totalWithService()+" EUR"; + } + static String replaceDescription(String before,String description){return "~~~\n"+description+"\n~~~\n"+before.replaceAll("(?ms)^~~~\\r?\\n.*?^~~~(?:\\r?\\n|$)","");} + static String description(Prepared p){var c=p.context();var r=c.rates();var a=p.amounts();return "BWV · "+text(c.courier(),"crvh_sid")+" · "+c.startLabel()+"\nHeimat-PLZ: "+c.homePostalCode()+" · Anfahrt: "+p.route().approachKm()+" km / "+p.route().approachMinutes()+" min\nRund-km: "+p.route().roundKm()+" × "+r.kilometer()+" EUR + "+r.roundMarkup()+" % = "+a.roundTotal()+" EUR\nPZM: "+a.pzmBase()+" EUR + "+r.pzmMarkup()+" % = "+a.pzmTotal()+" EUR\nGewählt: "+a.method()+" · "+a.selectedTotal()+" EUR"+(r.retour()?"\nRetourenmodus: Hinfahrt "+a.oneWayKm()+" km · "+a.payableTotal()+" EUR; Gesamttour hinterlegt":"")+"\nService: "+r.service()+" EUR · Frachtvergütung: "+a.totalWithService()+" EUR";} + Map view(Prepared p){if(p==null)return null;return fields("startMode",p.context().startMode(),"start",p.context().startLabel(),"homePostalCode",p.context().homePostalCode(),"route",p.route(),"rates",p.context().rates(),"amounts",p.amounts(),"description",description(p));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/LonghaulReturns.java b/backend/src/main/java/de/stadtbote/votian/LonghaulReturns.java new file mode 100644 index 0000000..87b158d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LonghaulReturns.java @@ -0,0 +1,151 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import java.math.*; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Native replacement for the BWV block in jobs/check_jb_permanent.php. */ +@Service +public class LonghaulReturns { + private final DatabaseService db; + private final OrderOperations orders; + private final boolean enabled; + private long cursor; + private volatile Run lastRun = new Run(null,0,0,0); + record Run(LocalDateTime at,int checked,int processed,int failed) {} + Run monitoringRun(){return lastRun;} + boolean monitoringEnabled(){return enabled;} + record Target(BigDecimal total,BigDecimal base) {} + record State(OrderOperations.Snapshot order,List> markers,List> followups) {} + record Decision(String state,String message,LocalDateTime due,BigDecimal current,BigDecimal total, + BigDecimal base,BigDecimal service,int followups,String processedAt) {} + public LonghaulReturns(DatabaseService db,OrderOperations orders, + @Value("${app.bwv-retour.enabled:false}") boolean enabled) { + this.db=db;this.orders=orders;this.enabled=enabled; + } + public Map view(LegacyUser user,long job) { + user.require(4); + return db.readTransaction(()->{ + var state=state(user,job,false); + var decision=decide(state,now()); + return fields("job",job,"workerEnabled",enabled,"lastRun",lastRun,"decision",decision); + }); + } + private LocalDateTime now(){return db.queryForObject("SELECT CURRENT_TIMESTAMP",java.sql.Timestamp.class).toLocalDateTime();} + private State state(LegacyUser user,long job,boolean lock) { + var order=orders.snapshot(user,job,lock); + if(lock&&id(order.job(),"cr_id")>0)db.lockRows("SELECT cr_id FROM courier WHERE cr_id=?",id(order.job(),"cr_id")); + String markerSql="SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cr_price_retour' ORDER BY gdc_id"; + var markers=lock?db.lockRows(markerSql,job):db.queryForList(markerSql,job); + // PHP deliberately searches across headquarters and does not count merely offered jobs. + String followSql="SELECT jb_id,hq_id FROM job WHERE cr_id=? AND cr_id>0 AND jb_longhaul=1 AND jb_status=1 AND COALESCE(jb_storno,0)=0 AND jb_id<>? ORDER BY jb_id"; + var followups=lock?db.lockRows(followSql,id(order.job(),"cr_id"),job):db.queryForList(followSql,id(order.job(),"cr_id"),job); + return new State(order,markers,followups); + } + static Target target(String value) { + String[] parts=value.split("\\|",-1); + if(parts.length!=2)throw bad("Die gespeicherte Retourenvergütung ist ungültig."); + var numbers=new ArrayList(); + for(String p:parts){ + if(!p.matches("[0-9]{1,6}(\\.[0-9]{1,4})?"))throw bad("Die gespeicherte Retourenvergütung ist ungültig."); + var n=new BigDecimal(p).setScale(2,RoundingMode.HALF_UP); + if(n.compareTo(new BigDecimal("999999.99"))>0)throw bad("Die Retourenvergütung überschreitet den zulässigen Betrag."); + numbers.add(n); + } + return new Target(numbers.get(0),numbers.get(1)); + } + private Decision decide(State s,LocalDateTime now) { + var job=s.order().job();BigDecimal current=decimal(job,"jb_cr_price"); + LocalDateTime finish=dateTime(job,"jb_finishtime"),due=finish==null?null:finish.plusMinutes(60); + if(s.markers().isEmpty())return new Decision("NONE","Für diesen Auftrag ist keine Retourenvergütung vorgemerkt.",due,current,null,null,null,0,""); + if(s.markers().size()!=1)return new Decision("BLOCKED","Mehrere Retourenvormerkungen vorhanden. Bitte den Bestand klären.",due,current,null,null,null,0,""); + String context=text(s.markers().getFirst(),"gdc_context"); + if(!context.isEmpty()){ + String message=context.startsWith("No follow-up|")?"Gesamtvergütung übernommen; kein angenommener BWV-Folgeauftrag vorhanden.":context.startsWith("Follow-up: ")?"BWV-Folgeauftrag vorhanden; bisherige Vergütung beibehalten.":context.startsWith("Manual price|")?"Retourenvormerkung durch manuelle Preisberichtigung beendet.":context.startsWith("Recalculation|")?"Retourenvormerkung durch Neukalkulation beendet.":"Retourenvormerkung bereits abgeschlossen."; + String at=context.contains("|")?context.substring(context.lastIndexOf('|')+1):""; + return new Decision("PROCESSED",message,due,current,null,null,null,0,at); + } + Target target;BigDecimal service; + try { + target=target(text(s.markers().getFirst(),"gdc_content")); + var services=s.order().priceDetails().stream().filter(p->id(p,"mt_sort")==3).toList(); + if(services.size()>1)throw bad("Mehrere Servicevergütungen vorhanden. Bitte die Preispositionen prüfen."); + service=services.isEmpty()?BigDecimal.ZERO:decimal(services.getFirst(),"jbprc_price"); + if(service==null||service.signum()<0||target.total().add(service).compareTo(new BigDecimal("999999.99"))>0||target.base().add(service).compareTo(new BigDecimal("999999.99"))>0)throw bad("Die Servicevergütung ist ungültig."); + var evidence=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_bwv_assignment'",id(job,"jb_id")); + if(evidence.size()>1)throw bad("Mehrere BWV-Zuweisungsbelege vorhanden. Bitte den Bestand klären."); + if(!evidence.isEmpty()){ + try { + var receipt=orders.support.json.readTree(text(evidence.getFirst(),"gdc_content")); + var amounts=receipt.path("amounts"); + if(!amounts.path("totalWithService").isNumber()||!amounts.path("payableBase").isNumber()||!amounts.path("selectedTotal").isNumber()||!amounts.path("selectedBase").isNumber()||!receipt.path("context").path("rates").path("service").isNumber())throw bad("Der BWV-Zuweisungsbeleg ist unvollständig."); + if(current==null||current.compareTo(amounts.path("totalWithService").decimalValue())!=0||decimal(job,"jb_cr_subprice")==null||decimal(job,"jb_cr_subprice").compareTo(amounts.path("payableBase").decimalValue())!=0||target.total().compareTo(amounts.path("selectedTotal").decimalValue())!=0||target.base().compareTo(amounts.path("selectedBase").decimalValue())!=0||service.compareTo(receipt.path("context").path("rates").path("service").decimalValue())!=0)throw bad("Die Vergütung wurde seit der BWV-Zuweisung geändert. Bitte vor der Retourenbuchung klären."); + }catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Der BWV-Zuweisungsbeleg ist ungültig.");} + } + if(current==null)throw bad("Die bisherige Frachtvergütung fehlt."); + if(id(job,"jb_longhaul")!=1||id(job,"cr_id")<=0)throw bad("Kein eindeutig zugeordneter BWV-Auftrag."); + orders.writable(s.order()); + if(s.order().priceDetails().stream().filter(p->id(p,"mt_sort")==10).count()!=1)throw bad("Die Vergütungsbasis fehlt oder ist mehrdeutig."); + }catch(org.springframework.web.server.ResponseStatusException e){ + return new Decision("BLOCKED",e.getReason(),due,current,null,null,null,0,""); + } + BigDecimal total=target.total().add(service).setScale(2,RoundingMode.HALF_UP); + if(id(job,"jb_status")!=2||due==null)return new Decision("WAITING","Die Prüfung beginnt 60 Minuten nach Erledigung des Auftrags.",due,current,total,target.base(),service,s.followups().size(),""); + if(due.isAfter(now))return new Decision("WAITING","Die Wartezeit nach Erledigung läuft noch.",due,current,total,target.base(),service,s.followups().size(),""); + return new Decision("READY",s.followups().isEmpty()?"Kein angenommener BWV-Folgeauftrag: Gesamtvergütung wird übernommen.":"Angenommener BWV-Folgeauftrag vorhanden: bisherige Vergütung bleibt erhalten.",due,current,total,target.base(),service,s.followups().size(),""); + } + /** Returns false for a consumed marker, a waiting job or an explicitly blocked record. */ + boolean process(long headquarters,long job,long marker) { + return db.writeTransaction(()->{ + var user=new LegacyUser(0,0,headquarters,"BWV-Retourenprüfung",""); + lockHeadquarters(db,user); + var state=state(user,job,true); + if(state.markers().size()!=1||id(state.markers().getFirst(),"gdc_id")!=marker)return false; + LocalDateTime now=now();var d=decide(state,now); + if(!d.state().equals("READY"))return false; + var row=state.order().job();String token=UUID.randomUUID().toString(); + boolean followup=!state.followups().isEmpty(); + String context=(followup?"Follow-up: "+id(state.followups().getFirst(),"jb_id"):"No follow-up")+"|"+now.format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")); + if(db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=? AND gdc_context=''",context,marker)!=1)return false; + if(!followup){ + db.update("UPDATE job SET jb_cr_price=?,jb_cr_subprice=?,jb_version=jb_version+1 WHERE jb_id=?",d.total(),d.base(),job); + db.update("UPDATE jobprice SET jbprc_price=? WHERE jb_id=? AND mt_sort=10",d.base().add(d.service()),job); + OrderExecutionAccounting.refreshCompleted(db,orders.catalog,job); + LocalDateTime expires=now.plusHours(36),orderTime=dateTime(row,"jb_ordertime"); + if(orderTime!=null&&orderTime.plusHours(36).isAfter(expires))expires=orderTime.plusHours(36); + String message="#"+job+", Frachtvergütung alt "+money(d.current())+" EUR, neu "+money(d.total())+" EUR\n"; + db.insert("`phoenix_pda`.commandexec",fields("cr_id",id(row,"cr_id"),"hq_id",headquarters,"cmd_command","7","cmde_mode","1","cmde_param",message,"cmde_exec_time",now.plusSeconds(20),"cmde_exec_timelimit",expires)); + orders.support.audit(user,job,0,id(row,"cr_id"),192,"$jb_cr_price_old="+d.current()+" · BWV-Retourenprüfung · neu="+d.total(),token); + } + db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_bwv_return","gdc_content",orders.support.encode(fields("marker",marker,"at",now,"decision",d,"followup",followup,"requestId",token)),"gdc_context",context)); + return true; + }); + } + private static String money(BigDecimal value){return value.setScale(2,RoundingMode.HALF_UP).toPlainString().replace('.',',');} + @Scheduled(initialDelay=60000,fixedDelay=60000) + public synchronized void run() { + if(!enabled)return; + int checked=0,processed=0,failed=0; + try { + orders.support.transactional("headquarters","job","jobprice","genericdatacontainer","courier","phoenix_pda.commandexec"); + // A cursor prevents malformed/blocked early rows from starving the rest of the queue. + var batch=db.readTransaction(()->db.queryForList("SELECT gdc_id,j.jb_id,j.hq_id FROM genericdatacontainer g JOIN job j ON j.jb_id=g.gdc_obj_id WHERE gdc_obj_type='jb' AND gdc_gen_fieldname='jb_cr_price_retour' AND gdc_context='' AND gdc_id>? AND j.jb_status=2 AND DATE_ADD(j.jb_finishtime,INTERVAL 60 MINUTE)<=CURRENT_TIMESTAMP ORDER BY gdc_id LIMIT 50",cursor)); + if(batch.isEmpty())cursor=0; + for(var row:batch){cursor=id(row,"gdc_id");checked++;try{if(process(id(row,"hq_id"),id(row,"jb_id"),cursor))processed++;}catch(RuntimeException e){failed++;}} + }catch(RuntimeException e){failed++;} + lastRun=new Run(LocalDateTime.now().withNano(0),checked,processed,failed); + } + static void cancelForManualPrice(DatabaseService db,long job) {cancel(db,job,"Manual price");} + static void cancelForRecalculation(DatabaseService db,long job) {cancel(db,job,"Recalculation");} + private static void cancel(DatabaseService db,long job,String reason) { + db.update("UPDATE genericdatacontainer SET gdc_context=CONCAT(?,'|',DATE_FORMAT(CURRENT_TIMESTAMP,'%Y-%m-%d %H:%i:%s')) WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_cr_price_retour' AND gdc_context=''",reason,job); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/LonghaulReturnsController.java b/backend/src/main/java/de/stadtbote/votian/LonghaulReturnsController.java new file mode 100644 index 0000000..7d166eb --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/LonghaulReturnsController.java @@ -0,0 +1,10 @@ +package de.stadtbote.votian; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +@RestController +@RequestMapping("/api/orders/{id}/bwv-return") +public class LonghaulReturnsController { + private final LonghaulReturns service; + public LonghaulReturnsController(LonghaulReturns service){this.service=service;} + @GetMapping public Object view(Authentication auth,@PathVariable long id){return service.view(ApiController.user(auth),id);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterAddressSuggestions.java b/backend/src/main/java/de/stadtbote/votian/MasterAddressSuggestions.java index 87b39f4..4994d73 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterAddressSuggestions.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterAddressSuggestions.java @@ -19,7 +19,10 @@ public class MasterAddressSuggestions { private String key(){String key=routing.apiKey();if(key.isBlank())throw unavailable("Adressvorschläge sind nicht konfiguriert. Die Adresse kann manuell eingegeben werden.");return key;} @GetMapping("/address-suggestions") public List suggestions(Authentication auth,@PathVariable String kind,@RequestParam String q,@RequestParam(defaultValue="DE")String country,@RequestParam String sessionToken){return suggestions(ApiController.user(auth),kind,q,country,sessionToken);} List suggestions(LegacyUser user,String kind,String query,String country,String session){ - access(user,kind);BusinessSupport.token(session); + access(user,kind);return suggestions(query,country,session); + } + List suggestions(String query,String country,String session){ + BusinessSupport.token(session); if(query==null||query.length()>200||country==null||!country.matches("[A-Z]{2}"))throw bad("Ungültige Adresssuche."); if(query.strip().length()<3)return List.of(); var payload=Map.of("input",query.strip(),"languageCode","de","regionCode",country.toLowerCase(Locale.ROOT),"sessionToken",session,"includedPrimaryTypes",List.of("street_address","route","premise","subpremise")); @@ -29,7 +32,10 @@ public class MasterAddressSuggestions { } @GetMapping("/address-details") public Address details(Authentication auth,@PathVariable String kind,@RequestParam String placeId,@RequestParam String sessionToken){return details(ApiController.user(auth),kind,placeId,sessionToken);} Address details(LegacyUser user,String kind,String placeId,String session){ - access(user,kind);BusinessSupport.token(session);if(!validId(placeId))throw bad("Ungültiger Adressvorschlag."); + access(user,kind);return details(placeId,session); + } + Address details(String placeId,String session){ + BusinessSupport.token(session);if(!validId(placeId))throw bad("Ungültiger Adressvorschlag."); var result=client.googlePlaces("places/"+placeId+"?languageCode=de&sessionToken="+session,null,key(),"addressComponents"); if(result==null||!result.path("addressComponents").isArray())throw unavailable("Die Adresse konnte nicht aufgelöst werden. Bitte manuell ergänzen."); return components(result.path("addressComponents")); diff --git a/backend/src/main/java/de/stadtbote/votian/MasterData.java b/backend/src/main/java/de/stadtbote/votian/MasterData.java index 8155dab..e24be00 100644 --- a/backend/src/main/java/de/stadtbote/votian/MasterData.java +++ b/backend/src/main/java/de/stadtbote/votian/MasterData.java @@ -13,10 +13,16 @@ import static de.stadtbote.votian.BusinessSupport.*; /** Customer/courier editors: inc_customer, inc_courier, csc_special and courier_vehicle. */ @Service public class MasterData { - final DatabaseService db; final BusinessSupport support; - public MasterData(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + final DatabaseService db; final BusinessSupport support; final OrderRoutingService routing; + public MasterData(DatabaseService db,BusinessSupport support,OrderRoutingService routing){this.db=db;this.support=support;this.routing=routing;} + CustomerPhotoSettings photoSettings(){return new CustomerPhotoSettings(db,support,new OrderIntegrationClient(support.json));} public record Field(String key,String label,String group,String type,int max,boolean required,Map options){} - public record Save(String requestId,String revision,Map values){} + public record Save(String requestId,String revision,Map values,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) String creationRevision,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) CustomerCopy.Source copySource,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) VehicleCopy.Source vehicleCopySource,@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) String customerMailFields){ + public Save(String requestId,String revision,Map values){this(requestId,revision,values,null,null,null,null);} + public Save(String requestId,String revision,Map values,String creationRevision){this(requestId,revision,values,creationRevision,null,null,null);} + public Save(String requestId,String revision,Map values,String creationRevision,CustomerCopy.Source copySource){this(requestId,revision,values,creationRevision,copySource,null,null);} + public Save(String requestId,String revision,Map values,String creationRevision,CustomerCopy.Source copySource,VehicleCopy.Source vehicleCopySource){this(requestId,revision,values,creationRevision,copySource,vehicleCopySource,null);} + } static Field f(String key,String label,String group,String type,int max,boolean required){return new Field(key,label,group,type,max,required,Map.of());} static Field choice(String key,String label,String group,String... options){var m=new LinkedHashMap();for(int i=0;i COMMON=List.of(f("tx_id","Umsatzsteuer","Bank und Rechnung","tax",0,true), @@ -29,31 +35,43 @@ public class MasterData { f("cmp_remark","Bemerkung","Notizen","textarea",10000,false),f("cmp_remark2","Interne Bemerkung","Notizen","textarea",10000,false),f("cmp_freetext1","Freitext","Notizen","textarea",10000,false)); static final List CUSTOMER=List.of( f("cs_eid","Kundennummer / ExtID","Firma","text",10,true), + f("cmp_type","Kundentyp","Vertrag und Status","customerType",3,false), choice("cs_invmode","Preisermittlung","Abrechnung","0","PZM","1","PLZ","2","Bereich"), f("cs_payment_term","Zahlungsbedingung","Abrechnung","payment",0,false),f("cs_discount","Rabatt (%)","Konditionen","percent",100,false),f("cs_fixprice_discount","Rabatt auf Fixpreise","Konditionen","check",1,false),f("cs_prov","Provision (%)","Konditionen","percent",100,false),f("cs_markup","Treibstoffaufschlag erheben","Konditionen","check",1,false),f("cs_markup_prov","Treibstoffaufschlag (%)","Konditionen","percent",100,false), f("cs_commission_no","Kommissionsnummer verpflichtend","Abrechnung","check",1,false),f("cs_jb_commission_no","Rechnungskommissionsnummer","Abrechnung","check",1,false),f("cs_jobbatch","Listenkunde","Auftragsvorgaben","check",1,false),f("cs_cr_fav_only","Nur angeforderte Kuriere","Auftragsvorgaben","check",1,false),f("cs_invtext_mode","Rechnungskurztext","Abrechnung","check",1,false),f("cs_tracking","Sendungsverfolgung","Auftragsvorgaben","check",1,false),f("cs_charging_time","Ladedauer (Minuten)","Auftragsvorgaben","integer",127,false),f("cs_blur_markup_time","Zeitaufschlag (Minuten)","Auftragsvorgaben","integer",127,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Auftragsvorgaben","check",1,false), f("cs_jbstatusmail","E-Mail bei Erledigung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail2","E-Mail bei Abholung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail3","E-Mail bei Erfassung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail2csc","Rechnungsadresse der Kostenstelle verwenden","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_price","Preis anzeigen","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_emp","Mitarbeiterdaten anzeigen","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_pdf","PDF anhängen","Benachrichtigungen","check",1,false), f("cmp_sustainability_email","Nachhaltigkeits-E-Mail","Benachrichtigungen","emails",250,false),f("cmp_sustainability_interval","Nachhaltigkeitsbericht","Benachrichtigungen","reportInterval",0,false)); - static final List COURIER=List.of(f("vht_id_inv","Abrechnungsfahrzeugtyp","Abrechnung","vehicle",0,false),f("cr_imei","Geräte-IMEI","Gerät","text",20,false),f("cr_serialno","Seriennummer","Gerät","text",30,false),f("cr_mobile_pda","Gerätetelefon","Gerät","text",30,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Disposition","check",1,false),f("cr_eid","Kuriernummer / ExtID","Firma","text",10,true),f("cr_maxweight","Maximale Zuladung (kg)","Disposition","integer",100000,false),f("cr_outlay","Maximale Auslage (€)","Disposition","integer",100000,false),f("cr_locationzipcode","Standort-PLZ","Disposition","text",10,false),f("cr_disposition","Disposition aktiviert","Disposition","check",1,false),f("cr_price_show","Preise anzeigen","Disposition","check",1,false)); + static final List COURIER=List.of(f("vht_id_inv","Abrechnungsfahrzeugtyp","Abrechnung","vehicle",0,false),f("cr_imei","Geräte-IMEI","Gerät","text",20,false),f("cr_serialno","Seriennummer","Gerät","text",30,false),f("cr_mobile_pda","Gerätetelefon","Gerät","readonly",30,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Disposition","check",1,false),f("cr_eid","Kuriernummer / ExtID","Firma","text",10,true),f("cr_maxweight","Maximale Zuladung (kg)","Disposition","integer",100000,false),f("cr_outlay","Maximale Auslage (€)","Disposition","integer",100000,false),f("cr_locationzipcode","Standort-PLZ","Disposition","text",10,false),f("cr_disposition","Disposition aktiviert","Disposition","check",1,false),f("cr_price_show","Preise anzeigen","Disposition","check",1,false)); static List centerFields(){var all=new ArrayList<>(CENTER);String[] groups={"","Leistungsanschrift","Rechnungsanschrift","Zusatzadresse (Typ 3)","Abhol- / Lieferadresse"};for(int type=1;type<=4;type++)for(var f:CENTER_ADDRESS)all.add(new Field("addr"+type+"_"+f.key(),f.label(),groups[type],f.type(),f.max(),type!=3&&f.required(),f.options()));return all;} static final List CENTER_ADDRESS=List.of(f("cscad_comp","Firma","","text",50,true),f("cscad_comp2","Zusatz","","text",50,false),f("cscad_comp3","Zusatz 2","","text",50,false),f("cscad_comp4","Zusatz 3","","text",50,false),f("ad_street","Straße","","text",50,true),f("cscad_hsno","Hausnummer","","text",10,false),f("ad_zipcode","PLZ","","text",10,true),f("ad_city","Ort","","text",50,true),f("ad_country","Land (ISO)","","country",2,true),f("cscad_person","Ansprechpartner","","text",50,false),f("cscad_phone","Telefon","","text",20,false),f("cscad_email","E-Mail","","emails",100,false),f("cscad_gln","GLN","","text",20,false),f("cscad_remark","Bemerkung","","textarea",10000,false)); static final List CENTER=List.of(f("csc_name","Kostenstellenname","Kostenstelle","text",50,true),f("csc_pre_id","Übergeordnete Kostenstelle","Kostenstelle","parent",0,true),f("csc_visible","Sichtbar","Kostenstelle","check",1,false),f("csc_blocked","Gesperrt","Kostenstelle","check",1,false),f("csc_is_extern","Externe Kostenstelle","Kostenstelle","check",1,false),f("csc_inv_interval","Rechnungsintervall","Abrechnung","interval",0,false),f("csc_inv_type","Rechnungstyp","Abrechnung","invoiceType",0,false)); - static final List VEHICLE=List.of(f("crvh_sid","Fahrzeugnummer / SID","Fahrzeug","text",10,true),f("vht_id","Fahrzeugtyp","Fahrzeug","vehicle",0,true),f("crvh_vh_sign","Kennzeichen","Fahrzeug","text",10,false),f("crvh_vh_name","Bezeichnung","Fahrzeug","text",30,false),f("crvh_comm_no","Kommissionsnummer","Fahrzeug","text",10,false),f("crvh_mobile","Mobiltelefon","Fahrzeug","text",20,false),f("crvh_licence_date","Erstzulassung","Fahrzeug","date",0,false),f("crvh_payload","Zuladung (kg)","Laderaum","integer",100000,false),f("crvh_totalweight","Gesamtgewicht (kg)","Laderaum","integer",100000,false),f("crvh_length","Länge (cm)","Laderaum","integer",10000,false),f("crvh_width","Breite (cm)","Laderaum","integer",10000,false),f("crvh_height","Höhe (cm)","Laderaum","integer",10000,false),f("crvh_position","Stellplätze","Laderaum","integer",1000,false),f("crvh_prov","Provision (%)","Abrechnung","percent",100,false),f("crvh_insurance_name","Versicherung","Vertrag","text",30,false),f("crvh_insurance_no","Versicherungsnummer","Vertrag","text",30,false),f("crvh_remark","Bemerkung","Vertrag","text",30,false),f("crvh_vhd_disabled","Fahrzeugdisposition deaktiviert","Fahrzeug","check",1,false),f("crvh_freetext_1","Freitext","Vertrag","textarea",10000,false)); - List fieldsFor(String kind){return switch(kind){case "customers"->{var a=new ArrayList<>(COMMON);a.addAll(CUSTOMER);yield a;}case "couriers"->{var a=new ArrayList<>(COMMON);a.addAll(COURIER);yield a;}case "centers"->centerFields();case "vehicles"->VEHICLE;default->throw bad("Unbekannter Stammdatenbereich.");};} + static final List VEHICLE=List.of(f("crvh_sid","Fahrzeugnummer / SID","Fahrzeug","text",10,true),f("vht_id","Fahrzeugtyp","Fahrzeug","vehicle",0,true),f("crvh_vh_sign","Kennzeichen","Fahrzeug","text",10,true),f("crvh_vh_name","Bezeichnung","Fahrzeug","text",30,false),f("crvh_comm_no","Kommissionsnummer","Fahrzeug","text",10,true),f("crvh_mobile","Mobiltelefon","Fahrzeug","text",20,false),f("crvh_licence_date","Erstzulassung (Monat/Jahr)","Fahrzeug","month",0,true),f("crvh_payload","Zuladung (kg)","Laderaum","integer",100000,false),f("crvh_totalweight","Gesamtgewicht (kg)","Laderaum","integer",100000,false),f("crvh_length","Länge (cm)","Laderaum","integer",10000,false),f("crvh_width","Breite (cm)","Laderaum","integer",10000,false),f("crvh_height","Höhe (cm)","Laderaum","integer",10000,false),f("crvh_position","Stellplätze","Laderaum","integer",1000,false),f("crvh_prov","Provision (%)","Abrechnung","percent",100,false),f("crvh_insurance_name","Versicherung","Vertrag","text",30,false),f("crvh_insurance_no","Versicherungsnummer","Vertrag","text",30,false),f("crvh_remark","Bemerkung","Vertrag","text",30,false),f("crvh_vhd_disabled","Fahrzeugdisposition deaktiviert","Fahrzeug","check",1,false),f("crvh_freetext_1","Freitext","Vertrag","textarea",10000,false)); + List fieldsFor(String kind){return switch(kind){case "customers"->{var a=new ArrayList<>(COMMON);a.addAll(CUSTOMER);a.addAll(CustomerCompanySettings.FIELDS);a.addAll(CustomerReferences.FIELDS);yield a;}case "couriers"->{var a=new ArrayList<>(COMMON);a.addAll(COURIER);yield a;}case "centers"->centerFields();case "vehicles"->{var a=new ArrayList<>(VEHICLE);a.addAll(VehicleMasterSettings.FIELDS);yield a;}default->throw bad("Unbekannter Stammdatenbereich.");};} void access(LegacyUser u,String kind){u.require(kind.equals("customers")||kind.equals("centers")?0:1);fieldsFor(kind);} public Map view(LegacyUser u,String kind,long id,long parent){access(u,kind);return db.readTransaction(()->{ Map row=id==0?new LinkedHashMap<>():load(u,kind,id,false); - if(kind.equals("centers")||kind.equals("vehicles")){load(u,kind.equals("centers")?"customers":"couriers",parent,false);if(id>0&&id(row,kind.equals("centers")?"cs_id":"cr_id")!=parent)throw bad("Falsche Zuordnung.");} - var values=new LinkedHashMap();for(var f:fieldsFor(kind)){Object value=row.get(f.key());if(value instanceof Boolean b)value=b?1:0;if(value!=null&&f.type().equals("date"))value=value.toString().startsWith("0000-")?"":value.toString().substring(0,10);if(f.type().equals("country")&&"D".equals(value))value="DE";values.put(f.key(),value==null?defaultValue(f):value);} + Map parentRow=Map.of();if(kind.equals("centers")||kind.equals("vehicles")){parentRow=load(u,kind.equals("centers")?"customers":"couriers",parent,false);if(id>0&&id(row,kind.equals("centers")?"cs_id":"cr_id")!=parent)throw bad("Falsche Zuordnung.");} + var values=new LinkedHashMap();for(var f:fieldsFor(kind)){Object value=row.get(f.key());if(value instanceof Boolean b)value=b?1:0;if(value!=null&&Set.of("date","month").contains(f.type()))value=value.toString().startsWith("0000-")?"":value.toString().substring(0,f.type().equals("month")?7:10);if(f.type().equals("country")&&"D".equals(value))value="DE";values.put(f.key(),value==null?defaultValue(f):value);} if(id==0&&kind.equals("centers")){var customer=load(u,"customers",parent,false);values.put("csc_pre_id",id(customer,"csc_id"));for(int type:List.of(1,2,4))for(var f:CENTER_ADDRESS){String key=f.key().replace("cscad_","cmp_");if(f.key().equals("cscad_person"))key="usr_name";if(f.key().equals("cscad_phone"))key="usr_phone";if(f.key().equals("cscad_email"))key="usr_inv_email";values.put("addr"+type+"_"+f.key(),customer.getOrDefault(key,defaultValue(f)));}} - var result=fields("id",id,"parent",parent,"fields",fieldsFor(kind),"values",values,"revision",revision(row),"options",options(u,kind,parent),"canManageServices",kind.equals("customers")&&u.hasRight(2)); + var entryRules=MasterEntryRules.read(db,u,kind);entryRules.defaults(id,values); + var result=fields("id",id,"parent",parent,"fields",entryRules.definitions(fieldsFor(kind),id),"identifier",entryRules.identifier(id),"fieldHints",entryRules.hints(id),"companyRules",entryRules.companyRules(),"values",values,"revision",revision(row),"options",options(u,kind,parent),"canContacts",kind.equals("customers")&&u.hasRight(0)&&u.hasRight(11),"canManagePortalEmployees",kind.equals("customers")&&u.hasRight(3),"canDocuments",Set.of("customers","couriers","vehicles").contains(kind)&&(u.hasRight(22)||u.hasRight(23)),"canManageRelations",Set.of("customers","couriers").contains(kind)&&u.hasRight(1),"canManageVehicleRelations",kind.equals("vehicles")&&u.hasRight(1),"canManageServices",kind.equals("customers")&&u.hasRight(2)); + if(id==0&&kind.equals("customers")){var creation=CustomerCreationDefaults.read(db,u);values.putAll(creation.mailDefaults());result.put("creationDefaults",creation);} if(id>0&&kind.equals("customers"))result.put("children",db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent,csc_blocked AS blocked FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name,csc_id",id)); - if(id>0&&kind.equals("couriers"))result.put("children",db.queryForList("SELECT crvh_id AS id,CONCAT(crvh_sid,' · ',COALESCE(crvh_vh_sign,'')) AS label FROM couriervehicle WHERE cr_id=? ORDER BY crvh_sid,crvh_id",id)); + if(id>0&&kind.equals("couriers"))result.put("children",db.queryForList("SELECT crvh_id AS id,CONCAT(crvh_sid,' · ',COALESCE(crvh_vh_sign,'')) AS label FROM couriervehicle WHERE cr_id=? AND COALESCE(crvh_modify_status,0)<>3 ORDER BY crvh_sid,crvh_id",id)); + if(kind.equals("customers")){ + new CustomerCompanySettings(db).decorate(u,row,values,result); + if(id>0&&u.hasRight(2))result.put("pendingPhotoSettings",db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngcsph' AND gdc_obj_id=? AND gdc_context NOT IN ('DONE','CONFLICT')",Integer.class,id)); + var type=new CustomerType(db,support);var state=type.view(id,row);result.put("customerType",state); + if(Boolean.TRUE.equals(state.get("hasOrders")))values.put("cmp_type",0); + } + if(kind.equals("couriers"))CourierMaskRules.read(db,u).decorate(result); + if(kind.equals("vehicles")){new VehicleMasterSettings(db).decorate(u,row,values,result);result.put("vehicleStock",new VehicleStock(db,support).view(u,id));result.put("associatedVehicleCourier",new VehicleAssociatedCourier(this).enabled());if(id>0){result.put("revision",VehicleActiveAssignment.revision(row,parentRow));result.put("vehicleActivation",VehicleActiveAssignment.display(parentRow));}} return result; });} - Object defaultValue(Field f){return switch(f.type()){case "country"->"DE";case "check"->Set.of("cmp_visible","cmp_authenticated","csc_visible").contains(f.key())?1:0;case "integer","money","percent","vehicle","parent","interval","invoiceType","payment","tax","reportInterval","select"->0;default->"";};} + Object defaultValue(Field f){return switch(f.type()){case "country"->"DE";case "check"->Set.of("cmp_visible","cmp_authenticated","csc_visible").contains(f.key())?1:0;case "integer","money","percent","vehicle","parent","interval","invoiceType","payment","tax","reportInterval","select","customerType","branch","salesEmployee","payerReference","customerReference"->0;default->"";};} Map options(LegacyUser u,String kind,long parent){var out=new LinkedHashMap(); + if(kind.equals("customers"))out.put("customerType",new CustomerType(db,support).options()); out.put("tax",db.queryForList("SELECT tx_id AS id,CONCAT(tx_sign,' · ',tx_value,' %') AS label FROM tax ORDER BY tx_id")); out.put("vehicle",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort")); out.put("payment",db.queryForList("SELECT mt_sort AS id,mt_mapped_value AS label FROM metatype WHERE mt_type='payment_term' ORDER BY mt_sort")); @@ -70,33 +88,69 @@ public class MasterData { case "vehicles"->"SELECT v.* FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE v.crvh_id=? AND c.hq_id=?"; default->throw bad("Unbekannter Stammdatenbereich.");}; var row=new LinkedHashMap<>(one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId()))); + if(kind.equals("vehicles")&&id(row,"crvh_modify_status")==3)throw bad("Das Fahrzeug wurde entfernt."); if(kind.equals("centers")){String addressSql="SELECT ca.*,a.* FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? ORDER BY ca.adt_id";for(var address:lock?db.lockRows(addressSql,id):db.queryForList(addressSql,id))address.forEach((k,v)->row.put("addr"+id(address,"adt_id")+"_"+k,v));}return row; } public Map save(LegacyUser u,String kind,long target,long parent,Save request){access(u,kind);if(request==null||request.values()==null)throw bad("Stammdaten fehlen.");token(request.requestId()); + if(request.copySource()!=null&&(!kind.equals("customers")||target!=0||parent!=0))throw bad("Eine Kundenkopie ist nur als neue Kundenanlage zulässig."); + if(request.vehicleCopySource()!=null&&(!kind.equals("vehicles")||target!=0||request.copySource()!=null))throw bad("Eine Fahrzeugkopie ist nur als neue Fahrzeuganlage zulässig."); + if(request.customerMailFields()!=null&&(!kind.equals("customers")||target!=0||request.copySource()!=null))throw bad("E-Mail-Startfelder sind nur bei einer neuen Kundenanlage zulässig."); + CustomerCreationDefaults.validateMailFields(request.customerMailFields()); var clean=validate(kind,request.values()); support.transactional("headquarters","genericdatacontainer","company","customer","courier","user","employee","address","costcenter","costcenteraddress","couriervehicle","servicepricehistory"); + // Resolve the postal center before opening the write transaction. A replay must + // not depend on the geocoder still being available after the original commit. + Object intent=List.of(kind,target,parent,request); + record HomePlan(Long receipt,boolean resolve){} + HomePlan plan=kind.equals("couriers")?db.readTransaction(()->{ + Long receipt=support.receipt(u,request.requestId(),intent);if(receipt!=null)return new HomePlan(receipt,false); + var old=target==0?Map.of():load(u,kind,target,false); + if(target>0)unchanged(old,request.revision()); + 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); + 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(); + } + 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. - one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId())); + // Serialize cross-branch customer references before the first consistent read. + if(kind.equals("customers")&&CustomerReferences.KEYS.stream().anyMatch(k->id(clean,k)>0))db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id"); + else one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId())); Long receipt=support.receipt(u,request.requestId(),List.of(kind,target,parent,request));if(receipt!=null)return receipt; - Map old=target==0?new LinkedHashMap<>():load(u,kind,target,true);if(target>0)unchanged(old,request.revision()); + Map old=target==0?new LinkedHashMap<>():load(u,kind,target,true);if(target>0&&!kind.equals("vehicles"))unchanged(old,request.revision()); + var entryRules=MasterEntryRules.read(db,u,kind);if(kind.equals("couriers"))CourierMaskRules.read(db,u).validate(old,clean);entryRules.validate(target,old,clean);entryRules.validateCompany(db,u,target,clean); + var copy=request.copySource()==null?null:new CustomerCopy(this).prepare(u,request.copySource(),clean); + var creation=target==0&&kind.equals("customers")&©==null?CustomerCreationDefaults.read(db,u):null;if(creation!=null)creation.check(request.creationRevision()); long result=target; if(kind.equals("customers")||kind.equals("couriers")){ - boolean cs=kind.equals("customers");String table=cs?"customer":"courier",key=cs?"cs_id":"cr_id",eid=cs?"cs_eid":"cr_eid"; + boolean cs=kind.equals("customers");if(cs){new CustomerType(db,support).prepare(target,old,clean);new CustomerCompanySettings(db).prepare(u,old,clean);new CustomerReferences(db).prepare(u,target,old,clean);}String table=cs?"customer":"courier",key=cs?"cs_id":"cr_id",eid=cs?"cs_eid":"cr_eid"; var duplicates=db.lockRows("SELECT "+key+" FROM "+table+" WHERE "+eid+"=? AND "+key+"<>?"+(cs?"":" AND hq_id=?"),cs?new Object[]{clean.get(eid),target}:new Object[]{clean.get(eid),target,u.headquartersId()});if(!duplicates.isEmpty())throw bad("Diese ExtID ist bereits vergeben."); if(cs&&priceChanged(old,clean)&&List.of("cs_discount","cs_prov","cs_markup_prov").stream().anyMatch(k->decimal(clean,k).signum()!=0 || (target>0 && decimal(old,k)!=null && decimal(old,k).signum()!=0)))u.require(2); - var company=prefix(clean,"cmp_");company.put("tx_id",clean.get("tx_id"));var person=prefix(clean,"usr_");var entity=prefix(clean,cs?"cs_":"cr_");if(!cs)entity.put("vht_id_inv",clean.get("vht_id_inv")); + var company=prefix(clean,"cmp_");if(cs)CustomerCompanySettings.company(old,clean,company);company.put("tx_id",clean.get("tx_id"));var person=prefix(clean,"usr_");var entity=prefix(clean,cs?"cs_":"cr_");if(cs)entity.put("csc_id_payer",clean.get("csc_id_payer"));if(!cs){if(!text(clean,"cr_mobile_pda").equals(text(old,"cr_mobile_pda")))throw bad("Bitte das Gerätetelefon über Gerätezuordnung / VPA-PDA ändern.");entity.remove("cr_mobile_pda");if(target==0)entity.put("cr_mobile_pda","");entity.put("vht_id_inv",clean.get("vht_id_inv"));if(home!=null)entity.putAll(fields("cr_gps_lat_home",home.latitude(),"cr_gps_long_home",home.longitude()));} long addressId=id(old,"ad_id");var address=prefix(clean,"ad_");boolean changed=target==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(e.getKey()),"").equals(e.getValue().toString())); if(changed)addressId=db.insert("address",address); // Never mutate shared addresses referenced by past jobs. company.put("ad_id",addressId);company.put("cmp_modify_status",1); if(target==0){ - company.put("cmp_fingerprint","");company.put("cmp_new_date",LocalDateTime.now());company.putIfAbsent("cmp_contract_date","0000-00-00");company.putIfAbsent("cmp_sustainability_email","");company.put("cmp_newsletter",0); + company.put("cmp_fingerprint","");company.put("cmp_new_date",LocalDateTime.now());company.putIfAbsent("cmp_contract_date","0000-00-00");company.putIfAbsent("cmp_sustainability_email","");company.putIfAbsent("cmp_newsletter",0); long cmp=db.insert("company",company); person.putAll(fields("hq_id",u.headquartersId(),"usr_type",cs?2:3,"usr_country","","usr_account","","usr_password",null,"usr_password_old","","usr_totp_secret","","usr_totp_sessionkey",""));long usr=db.insert("user",person); - entity.putAll(fields("hq_id",u.headquartersId(),"cmp_id",cmp)); - if(cs){long center=db.insert("costcenter",fields("csc_name",clean.get("cmp_comp"),"csc_visible",1));long emp=db.insert("employee",fields("usr_id",usr,"csc_id",center,"emp_rights","0".repeat(70),"emp_heritable_rights","0".repeat(70),"emp_headquarters",""));entity.putAll(fields("csc_id",center,"cs_admin",emp,"cs_become_cs_date",LocalDate.now()));result=db.insert(table,entity);db.update("UPDATE costcenter SET cs_id=? WHERE csc_id=?",result,center);for(int type:List.of(1,2,4))addressCopy(center,type,addressId,clean);} - else{entity.putAll(fields("usr_id",usr,"cr_filter","","cr_device_info","","cr_mobile_cell",""));result=db.insert(table,entity);} + entity.putAll(fields("hq_id",u.headquartersId(),"cmp_id",cmp));if(creation!=null){entity.put("cs_ar",creation.automaticDispatch()?1:0);entity.put("cs_jbstatusmail_fields",request.customerMailFields()==null?creation.mailFields():request.customerMailFields());}if(copy!=null)new CustomerCopy(this).entity(copy,entity); + if(cs){long center=db.insert("costcenter",fields("csc_name",clean.get("cmp_comp"),"csc_visible",1));long emp=db.insert("employee",fields("usr_id",usr,"csc_id",center,"emp_rights",PortalEmployees.DEFAULT_ROOT_RIGHTS,"emp_heritable_rights",PortalEmployees.DEFAULT_ROOT_RIGHTS,"emp_headquarters",""));entity.putAll(fields("csc_id",center,"cs_admin",emp,"cs_become_cs_date",id(clean,"cmp_type")==0?LocalDate.now():"0000-00-00"));result=db.insert(table,entity);if(creation!=null)creation.created(db,support,u,result);if(copy!=null)new CustomerCopy(this).created(u,result,copy,clean);db.update("UPDATE costcenter SET cs_id=? WHERE csc_id=?",result,center);for(int type:List.of(1,2,4))addressCopy(center,type,addressId,clean);} + else{entity.putAll(fields("usr_id",usr,"cr_filter",",VA,","cr_app_blocked",0,"cr_device_info","","cr_mobile_cell",""));result=db.insert(table,entity);} + MetaObjectRegistration.enqueue(db,support,u.headquartersId(),cs?"cs":"cr",result); + if(!cs)MetaObjectRegistration.enqueue(db,support,u.headquartersId(),"usr",usr); }else{update(db,"company","cmp_id",id(old,"cmp_id"),company);update(db,"user","usr_id",id(old,"usr_id"),person);update(db,table,key,result,entity);} + if(cs)new CustomerType(db,support).changed(u,result,old,clean,target==0,request.requestId()); + if(cs&&target>0&&!text(old,"cs_eid").equals(text(clean,"cs_eid")))CustomerPreviousNumber.refreshNumber(db,support,result,text(clean,"cs_eid")); + if(cs&&target>0&&id(old,"cs_commission_no")==1&&id(clean,"cs_commission_no")==0)CustomerEntrySettings.clearStationReferences(db,support,result); if(cs)for(String field:List.of("cs_discount","cs_prov","cs_markup_prov"))if(target==0||decimalChanged(old,clean,field))history("cs",result,clean.get(eid).toString(),field,clean.get(field)); }else if(kind.equals("centers")){ var customer=load(u,"customers",parent,true);if(target>0&&id(old,"cs_id")!=parent)throw bad("Falscher Kunde."); @@ -110,17 +164,38 @@ public class MasterData { } rebuildPaths(parent); }else{ - load(u,"couriers",parent,true);if(target>0&&id(old,"cr_id")!=parent)throw bad("Falscher Transporteur."); + var vehicleOwner=load(u,"couriers",parent,true);if(target>0&&id(old,"cr_id")!=parent)throw bad("Falscher Transporteur."); + if(target>0&&!VehicleActiveAssignment.revision(old,vehicleOwner).equals(request.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Fahrzeug oder aktive Fahrerzuordnung wurden inzwischen geändert. Bitte das Stammblatt neu öffnen."); + VehicleActiveAssignment.weightChanged(old,clean); + var vehicleSettings=new VehicleMasterSettings(db);vehicleSettings.prepare(u,old,clean); + if(request.vehicleCopySource()!=null)new VehicleCopy(this).prepare(u,parent,request.vehicleCopySource(),clean); if(decimalChanged(old,clean,"crvh_prov")&&(target>0||decimal(clean,"crvh_prov").signum()!=0))u.require(41); - if(!db.lockRows("SELECT crvh_id FROM couriervehicle WHERE crvh_sid=? AND crvh_id<>?",clean.get("crvh_sid"),target).isEmpty())throw bad("Die Fahrzeug-SID ist bereits vergeben."); + VehicleIdentityRules.unique(db,u.headquartersId(),target,clean); if(db.queryForList("SELECT mt_id FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",clean.get("vht_id")).isEmpty())throw bad("Unbekannter Fahrzeugtyp."); clean.put("cr_id",parent);clean.put("crvh_modify_status",1); - if(target==0){clean.putAll(fields("crvh_filter","","crvh_service","","crvh_funding_state",0,"crvh_funding_residual_value",0,"crvh_partner_text","","crvh_transport_insurance_date","0000-00-00","crvh_business_liability_insurance_date","0000-00-00"));result=db.insert("couriervehicle",clean);}else update(db,"couriervehicle","crvh_id",result,clean); + if(target==0){clean.putIfAbsent("crvh_filter","");clean.putIfAbsent("crvh_service","");clean.putAll(fields("crvh_funding_residual_value",0,"crvh_partner_text",""));clean.putIfAbsent("crvh_funding_state",0);clean.putIfAbsent("crvh_transport_insurance_date","0000-00-00");clean.putIfAbsent("crvh_business_liability_insurance_date","0000-00-00");result=db.insert("couriervehicle",clean);}else update(db,"couriervehicle","crvh_id",result,clean); + vehicleSettings.history(this,target,result,old,clean); + new VehicleMasterDisposition(db,support).saved(target,result,old,clean); + if(target>0)VehicleActiveAssignment.saved(db,vehicleOwner,clean); + 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()); } - 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.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; - });return view(u,kind,saved,parent); + });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()); } + Map copyResult(LegacyUser u,long customer,String token){ + var photos=photoSettings();var row=db.readTransaction(()->photos.find(token,false));CustomerPhotoSettings.Operation operation=null; + if(!row.isEmpty()){ + try{operation=photos.deliver(u,customer,token);}catch(RuntimeException e){ + // Customer creation has committed. Surface its separate durable delivery state. + var latest=db.readTransaction(()->photos.find(token,false));operation=photos.operation(u,latest); + } + } + var result=view(u,"customers",customer,0);if(operation!=null)result.put("photoCopy",operation);return result; + } + public Map suggestIdentifier(LegacyUser u,String kind){access(u,kind);return db.readTransaction(()->MasterEntryRules.read(db,u,kind).suggestion(db));} static int csOperation(String kind,long id){return kind.equals("customers")?(id==0?34:36):kind.equals("couriers")?(id==0?35:37):49;} static boolean decimalChanged(Map a,Map b,String k){BigDecimal x=decimal(a,k),y=decimal(b,k);return x==null||y==null||x.compareTo(y)!=0;} static boolean priceChanged(Mapa,Mapb){return List.of("cs_discount","cs_prov","cs_markup_prov").stream().anyMatch(k->decimalChanged(a,b,k));} @@ -131,16 +206,17 @@ public class MasterData { Map validate(String kind,Map values){var result=validateFields(fieldsFor(kind),kind,values);if(kind.equals("centers")&&optionalAddressPresent(result))for(var field:CENTER_ADDRESS)if(field.required()&&text(result,"addr3_"+field.key()).isBlank())throw bad("Zusatzadresse: "+field.label()+" fehlt.");if(result.containsKey("tx_id")&&db.queryForList("SELECT tx_id FROM tax WHERE tx_id=?",result.get("tx_id")).isEmpty())throw bad("Bitte eine gültige Umsatzsteuer wählen.");for(var f:fieldsFor(kind)){String type=switch(f.type()){case "payment"->"payment_term";case "reportInterval"->"interval";case "interval"->"invoiceinterval";case "invoiceType"->"invoicetype";case "vehicle"->"vehicletype";default->null;};if(type!=null&&((Number)result.get(f.key())).longValue()!=0&&db.queryForList("SELECT mt_id FROM metatype WHERE mt_type=? AND mt_sort=?",type,result.get(f.key())).isEmpty())throw bad(f.label()+": Ungültige Auswahl.");}return result;} static boolean optionalAddressPresent(Map values){return CENTER_ADDRESS.stream().filter(f->!f.key().equals("ad_country")).anyMatch(f->!text(values,"addr3_"+f.key()).isBlank());} static Map validateFields(List allowed,String kind,Map values){var result=new LinkedHashMap();if(values.keySet().stream().anyMatch(k->allowed.stream().noneMatch(f->f.key().equals(k))))throw bad("Unbekanntes Stammdatenfeld."); - for(var f:allowed){String v=Objects.toString(values.get(f.key()),"").trim();Object parsed=v; + for(var f:allowed){if(kind.equals("vehicles")&&VehicleMasterSettings.KEYS.contains(f.key())&&!values.containsKey(f.key()))continue;if(kind.equals("customers")&&(CustomerCompanySettings.KEYS.contains(f.key())||CustomerReferences.KEYS.contains(f.key()))&&!values.containsKey(f.key()))continue;String v=Objects.toString(values.get(f.key()),"").trim();Object parsed=v; if(f.required()&&v.isEmpty())throw bad(f.label()+" fehlt."); switch(f.type()){ case "check"->{if(!Set.of("0","1","true","false","").contains(v))throw bad(f.label()+": Ungültiger Wert.");parsed=Set.of("1","true").contains(v)?1:0;} - case "integer","money","percent","parent","vehicle","payment","interval","invoiceType","tax","reportInterval"->{try{BigDecimal n=new BigDecimal(v.isEmpty()?"0":v.replace(',','.'));boolean decimal=Set.of("money","percent").contains(f.type());if(n.signum()<0||n.compareTo(BigDecimal.valueOf(f.max()>0?f.max():Integer.MAX_VALUE))>0||n.stripTrailingZeros().scale()>(decimal?2:0))throw new NumberFormatException();parsed=decimal?n:n.longValueExact();}catch(ArithmeticException|NumberFormatException e){throw bad(f.label()+": Ungültige Zahl.");}} + case "integer","money","percent","parent","vehicle","payment","interval","invoiceType","tax","reportInterval","customerType","branch","salesEmployee","payerReference","customerReference"->{try{BigDecimal n=new BigDecimal(v.isEmpty()?"0":v.replace(',','.'));boolean decimal=Set.of("money","percent").contains(f.type());if(n.signum()<0||n.compareTo(BigDecimal.valueOf(f.max()>0?f.max():Integer.MAX_VALUE))>0||n.stripTrailingZeros().scale()>(decimal?2:0))throw new NumberFormatException();parsed=decimal?n:n.longValueExact();}catch(ArithmeticException|NumberFormatException e){throw bad(f.label()+": Ungültige Zahl.");}} case "select"->{if(!f.options().containsKey(v))throw bad(f.label()+": Ungültige Auswahl.");parsed=Integer.parseInt(v);} case "datetime-local"->{try{parsed=v.isEmpty()?null:LocalDateTime.parse(v.replace(' ','T'));}catch(Exception e){throw bad(f.label()+": Ungültiger Zeitpunkt.");}} + case "month"->{try{parsed=v.isEmpty()?null:f.key().equals("crvh_licence_date")?VehicleIdentityRules.licenceMonth(v):java.time.YearMonth.parse(v).atDay(1);}catch(Exception e){throw bad(f.label()+": Ungültiger Monat.");}} case "date"->{try{parsed=v.isEmpty()?(kind.equals("vehicles")?null:"0000-00-00"):LocalDate.parse(v);}catch(Exception e){throw bad(f.label()+": Ungültiges Datum.");}} default->{if(v.length()>f.max())throw bad(f.label()+" ist zu lang.");if(f.type().equals("country")&&(!v.isEmpty()||f.required())&&!v.matches("[A-Z]{2}"))throw bad("Das Land benötigt einen ISO-Ländercode wie DE.");if(f.type().equals("emails")&&!v.isEmpty())for(String email:v.split("[;,]"))if(!email.trim().matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+"))throw bad(f.label()+": Ungültige E-Mail-Adresse.");} }result.put(f.key(),parsed); - }return result; + }if(kind.equals("customers")&&!values.containsKey("cmp_type"))result.remove("cmp_type");return result; } } diff --git a/backend/src/main/java/de/stadtbote/votian/MasterDirectory.java b/backend/src/main/java/de/stadtbote/votian/MasterDirectory.java new file mode 100644 index 0000000..9e092d5 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterDirectory.java @@ -0,0 +1,99 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.util.*; +import java.nio.charset.StandardCharsets; +import org.springframework.http.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** SQL-backed customer/courier directory with personal PHP columns. */ +@RestController +class MasterDirectory { + record Query(String search,Map filters,String sort,boolean descending,int offset,boolean costCenters,boolean includeHidden, + Map criteria,boolean prefix,List headquarters,boolean includeInternal,boolean deviceInfo,List requirements){ + Query(String search,Map filters,String sort,boolean descending,int offset,boolean costCenters,boolean includeHidden){this(search,filters,sort,descending,offset,costCenters,includeHidden,Map.of(),false,List.of(),false,false,List.of());} + } + record Export(Query query,List columns,String delimiter,boolean header){} + @PostMapping("/api/master-lists/{kind}/csv") ResponseEntity export(Authentication a,@PathVariable MasterListColumns.Kind kind,@RequestBody Export command){ + return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+(kind==MasterListColumns.Kind.customers?"kunden":"transporteure")+".csv\"").body(csv(ApiController.user(a),kind,command)); + } + byte[] csv(LegacyUser u,MasterListColumns.Kind kind,Export command){ + u.require(kind.right());u.require(kind==MasterListColumns.Kind.customers?51:48); + if(command==null||command.columns()==null||command.columns().isEmpty()||command.columns().size()>50||new HashSet<>(command.columns()).size()!=command.columns().size()||!Set.of(";",",","|","\t").contains(command.delimiter()))throw bad("Bitte gültige Exportspalten und ein Trennzeichen wählen."); + return db.readTransaction(()->{ + var result=execute(u,kind,command.query(),command.columns()); + @SuppressWarnings("unchecked") var rows=(List>)result.get("items"); + @SuppressWarnings("unchecked") var definitions=(List>)result.get("definitions"); + var out=new StringBuilder("\uFEFF"); + if(command.header()){var names=new ArrayList();for(String key:command.columns())names.add(ApiController.csvCell(definitions.stream().filter(d->text(d,"key").equals(key)).map(d->text(d,"label")).findFirst().orElse(key)));out.append(String.join(command.delimiter(),names)).append("\r\n");} + for(var row:rows){@SuppressWarnings("unchecked") var cells=(Map)row.get("cells");var line=new ArrayList();for(String key:command.columns())line.add(ApiController.csvCell(cells.get(key)));out.append(String.join(command.delimiter(),line)).append("\r\n");} + return out.toString().getBytes(StandardCharsets.UTF_8); + }); + } + static final Set ACTIONS=Set.of("csc_id_jb_edit","cs_id_history","cs_id_report","cs_id_jb_permanent","cmp_id_edit","cr_id_history","cr_id_report"); + final DatabaseService db; + MasterDirectory(DatabaseService db){this.db=db;} + @PostMapping("/api/master-lists/{kind}") Object query(Authentication a,@PathVariable MasterListColumns.Kind kind,@RequestBody Query q){return query(ApiController.user(a),kind,q);} + static String meta(String type,String column){return "(SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='"+type+"' AND mt.mt_sort="+column+")";} + static String generic(String type,String id,String field){return "(SELECT MIN(g.gdc_content) FROM genericdatacontainer g WHERE g.gdc_obj_type='"+type+"' AND g.gdc_obj_id="+id+" AND g.gdc_gen_fieldname='"+field+"')";} + static LinkedHashMap columns(MasterListColumns.Kind kind,boolean classic){ + var result=new LinkedHashMap(); + for(String key:kind.keys){if(ACTIONS.contains(key)||key.equals("cmp_authenticated_img"))continue; + String table=key.startsWith("usr_")?"u":key.startsWith("cmp_")?"cmp":key.startsWith("ad_")?"a":key.startsWith("cs_")?"cs":key.startsWith("crvh_")?"cv":key.startsWith("cr_")?"cr":key.startsWith("tx_")?"tx":"br";result.put(key,table+"."+key);} + if(kind==MasterListColumns.Kind.customers){ + result.put("cs_num_of_documents",generic("cs","cs.cs_id","num_of_documents"));result.put("cs_eid_old",generic("cs","cs.cs_id","eid_old")); + result.put("cs_become_cs_date","NULLIF(CAST(cs.cs_become_cs_date AS CHAR),'0000-00-00')"); + result.put("mt_value_payment_term","(SELECT MIN(CONCAT_WS(' :: ',mt.mt_value,mt.mt_mapped_value)) FROM metatype mt WHERE mt.mt_type='payment_term' AND mt.mt_sort=cs.cs_payment_term)"); + result.put("cmp_inv2hq","CASE WHEN cmp.cmp_inv2hq=0 THEN 'Nein' ELSE 'Ja' END"); + result.put("cmp_authenticated_img","CONCAT_WS(' · ',CASE WHEN EXISTS(SELECT 1 FROM customer sub WHERE sub.cs_id_parent=cs.cs_id) THEN 'Metakunde' WHEN cmp.cmp_type=0 THEN 'Kunde' ELSE 'Interessent' END,CASE WHEN cs.cs_id_parent>0 THEN 'Hat Metakunden' END,CASE WHEN cmp.cmp_authenticated=1 THEN 'Freigegeben' ELSE 'Gesperrt' END,CASE WHEN cs.cs_jbedit=1 THEN 'Eigenerfassung' END)"); + result.put("csc_name","cc.csc_name"); + }else{ + result.put("cr_sid",classic?"cr.cr_sid":"cv.crvh_sid");result.put("crvh_vh_sign",classic?"cr.cr_vh_sign":"cv.crvh_vh_sign"); + result.put("cr_num_of_documents",generic("cr","cr.cr_id","num_of_documents"));result.put("crvh_num_of_documents",generic("crvh","cv.crvh_id","num_of_documents")); + result.put("crvh_measurement","CONCAT_WS(' × ',cv.crvh_length,cv.crvh_width,cv.crvh_height,cv.crvh_position)"); + result.put("crvh_licence_date","NULLIF(LEFT(CAST(cv.crvh_licence_date AS CHAR),7),'0000-00')");result.put("crvh_drivetype",meta("drivetype","cv.crvh_drivetype"));result.put("crvh_corporate_identity",meta("vehicle_ci","cv.crvh_corporate_identity"));result.put("vht_id_inv",meta("vehicletype","cr.vht_id_inv")); + result.put("cmp_authenticated_img","CASE WHEN cmp.cmp_authenticated=1 THEN 'Freigegeben' ELSE 'Gesperrt' END"); + }return result; + } + Map query(LegacyUser u,MasterListColumns.Kind kind,Query q){u.require(kind.right());return db.readTransaction(()->execute(u,kind,q,null));} + Map execute(LegacyUser u,MasterListColumns.Kind kind,Query q,List export){ + if(q==null||q.offset()<0||q.offset()>1000000||q.filters()==null||q.filters().size()>50||q.sort()==null)throw bad("Ungültige Stammdatensuche."); + boolean cs=kind==MasterListColumns.Kind.customers,classic=!cs&&new OrderEntryCatalog(db).enabled(0,"CR_SID_STATUS");var fields=columns(kind,classic); + fields.put("headquarters","hq.hq_mnemonic");if(!cs)fields.put("deviceInfo","cr.cr_device_info"); + if(!fields.containsKey(q.sort())||!fields.keySet().containsAll(q.filters().keySet()))throw bad("Unbekannte Such- oder Sortierspalte."); + if(export!=null&&!fields.keySet().containsAll(export))throw bad("Unbekannte Exportspalte oder Aktionsspalte im Export."); + var access=MasterDirectoryScope.access(db,u,kind);var hqs=MasterDirectoryScope.select(u,access,q.headquarters()); + if(q.includeInternal()&&(cs||!access.root()))throw MasterDirectoryScope.denied(); + var definitions=MasterDirectoryFilters.definitions(db,kind); + var layout=MasterListColumns.runtime(db,u,kind);var selected=new ArrayList<>(layout.columns()); + if(cs&&q.costCenters()&&!selected.contains("csc_name"))selected.addFirst("csc_name"); + if(hqs.size()>1||hqs.getFirst()!=u.headquartersId())selected.addFirst("headquarters");if(!cs&&q.deviceInfo())selected.add("deviceInfo"); + String from=cs?" FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN branch br ON br.br_id=cmp.br_id LEFT JOIN tax tx ON tx.tx_id=cmp.tx_id LEFT JOIN costcenter cc ON cc.cs_id=cs.cs_id"+(q.costCenters()?"":" AND cc.csc_id=cs.csc_id"):" FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN user u ON u.usr_id=cr.usr_id LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN couriervehicle cv ON cv.cr_id=cr.cr_id"+(classic?" AND 1=0":""); + String owner=cs?"cs":"cr",marks=String.join(",",Collections.nCopies(hqs.size(),"?")); + var sql=new StringBuilder(from).append(" JOIN headquarters hq ON hq.hq_id=").append(owner).append(".hq_id WHERE ").append(owner).append(".hq_id IN (").append(marks).append(") AND u.hq_id IN (").append(marks).append(") AND COALESCE(cmp.cmp_archived,0)=0"); + var args=new ArrayList(hqs);args.addAll(hqs); + boolean onlyHidden=q.criteria()!=null&&"1".equals(q.criteria().get("onlyHidden"));if(!q.includeHidden()&&!onlyHidden)sql.append(" AND cmp.cmp_visible=1"); + if(!cs&&!q.includeInternal())sql.append(" AND COALESCE(cmp.cmp_visible_internal,0)=0"); + if(q.search()!=null&&!q.search().isBlank()){String term=EntryCostCenterSearch.term(q.search(),q.prefix());sql.append(" AND (");var clauses=new ArrayList();for(String key:cs?List.of("cmp_comp","cmp_comp2","cs_eid","ad_city","cmp_match","usr_name","usr_email"):List.of("cmp_comp","cr_eid","cr_sid","usr_name","crvh_vh_sign","ad_city")){clauses.add(fields.get(key)+" LIKE ? ESCAPE '!'");args.add(term);}sql.append(String.join(" OR ",clauses)).append(')');} + for(String key:new TreeSet<>(q.filters().keySet())){String value=q.filters().get(key);if(value==null||value.isBlank())continue; + if(!cs&&Set.of("crvh_length","crvh_width","crvh_height","crvh_position","crvh_payload","crvh_totalweight","crvh_env_badge_level","crvh_corporate_identity").contains(key)){sql.append(" AND cv.").append(key).append(">=?");args.add(MasterDirectoryFilters.number(value.strip()));} + else if(!cs&&key.equals("crvh_measurement")){var n=MasterDirectoryFilters.number(value.strip());sql.append(" AND (cv.crvh_length=? OR cv.crvh_width=? OR cv.crvh_height=? OR cv.crvh_position=?)");args.addAll(List.of(n,n,n,n));} + else if(!cs&&key.equals("crvh_licence_date")){String month=value.replace("-","");if(!month.matches("[0-9]{6}"))throw bad("Erstzulassung bitte als JJJJ-MM eingeben.");try{java.time.YearMonth.parse(month.substring(0,4)+"-"+month.substring(4));}catch(Exception e){throw bad("Ungültiger Zulassungsmonat.");}sql.append(" AND DATE_FORMAT(cv.crvh_licence_date,'%Y%m')>=?");args.add(month);} + else {sql.append(" AND ").append(fields.get(key)).append(" LIKE ? ESCAPE '!'");args.add(EntryCostCenterSearch.term(value,q.prefix()));} + } + MasterDirectoryFilters.apply(sql,args,q.criteria(),definitions,q.prefix()); + var requirements=cs?List.>of():db.queryForList("SELECT crf_short AS code,crf_type AS type,crf_text AS label FROM courierfilter WHERE crf_type IN (0,1) ORDER BY crf_short,crf_type"); + if(q.requirements()!=null){if(q.requirements().size()>100||cs&&!q.requirements().isEmpty())throw bad("Ungültige Anforderungen.");for(String code:new LinkedHashSet<>(q.requirements())){var matches=requirements.stream().filter(r->text(r,"code").equals(code)).toList();if(matches.size()!=1||code==null||code.isBlank()||code.contains(","))throw bad("Unbekanntes oder mehrdeutiges Filterkürzel.");sql.append(" AND ").append(id(matches.getFirst(),"type")==1?"cr.cr_filter":"cv.crvh_filter").append(" LIKE ? ESCAPE '!'");args.add(EntryCostCenterSearch.term(","+code+",",false));}} + long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*)"+sql,Long.class,args.toArray())).orElse(0L); + if(export!=null&&total>100000)throw bad("Mehr als 100.000 Exportzeilen. Bitte die Suche eingrenzen; es wurde keine Teildatei erzeugt."); + var select=new ArrayList();fields.forEach((key,value)->select.add(value+" AS "+key));var pageArgs=new ArrayList<>(args);if(export==null)pageArgs.add(q.offset()); + var rows=db.queryForList("SELECT "+(cs?"cs.cs_id AS id,cc.csc_id AS child,cs.csc_id AS rootCenter":"cr.cr_id AS id,cv.crvh_id AS child,0 AS rootCenter")+","+owner+".hq_id AS headquartersId,cmp.cmp_comp AS name,"+String.join(",",select)+sql+" ORDER BY "+fields.get(q.sort())+(q.descending()?" DESC":" ASC")+","+(cs?"cs.cs_id,cc.csc_id":"cr.cr_id,cv.crvh_id")+(export==null?" LIMIT 100 OFFSET ?":" LIMIT 100001"),pageArgs.toArray()); + if(export!=null&&rows.size()>100000)throw bad("Die Exportauswahl ist zu groß. Bitte die Suche eingrenzen."); + var items=rows.stream().map(r->{var values=new LinkedHashMap();fields.keySet().forEach(k->values.put(k,text(r,k)));return fields("id",id(r,"id"),"child",id(r,"child"),"rootCenter",id(r,"rootCenter"),"name",text(r,"name"),"headquartersId",id(r,"headquartersId"),"cells",values);}).toList(); + var columnDefs=new ArrayList<>(MasterListColumns.definitions(kind));if(cs)columnDefs.add(fields("key","csc_name","label","Kostenstelle"));else columnDefs.add(fields("key","deviceInfo","label","VPA-Geräteinfo"));columnDefs.add(fields("key","headquarters","label","Niederlassung")); + return fields("items",items,"total",total,"offset",q.offset(),"limit",100,"columns",selected,"definitions",columnDefs,"warning",layout.warning(),"classic",classic,"canDocuments",u.hasRight(22)||u.hasRight(23)||access.root(),"canEdit",u.hasRight(kind.right()),"canCreateOrders",cs&&u.hasRight(7),"canReports",u.hasRight(16),"canHistory",u.hasRight(kind.right()),"canRecurring",cs&&u.hasRight(46)&&u.hasRight(4)&&u.hasRight(7),"canExport",u.hasRight(cs?51:48),"canInternal",!cs&&access.root(),"currentHeadquarters",u.headquartersId(),"headquarters",access.branches(),"criteria",MasterDirectoryFilters.publicDefinitions(definitions),"requirements",requirements); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterDirectoryFilters.java b/backend/src/main/java/de/stadtbote/votian/MasterDirectoryFilters.java new file mode 100644 index 0000000..bc1a6a9 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterDirectoryFilters.java @@ -0,0 +1,69 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Allowlisted advanced filters from the current PHP customer/courier lists. */ +final class MasterDirectoryFilters { + record Definition(String key,String label,String type,String group,String sql,List> options){} + static void add(Map d,String key,String label,String type,String group,String sql){d.put(key,new Definition(key,label,type,group,sql,List.of()));} + static void flag(Mapd,String key,String label,String group,String sql){add(d,key,label,"checkbox",group,sql);} + static void parameter(Mapd,String key,String label,String prefix){flag(d,key,label,"Erfassung und Versand","EXISTS(SELECT 1 FROM parameter fp WHERE fp.par_key=CONCAT('"+prefix+"',cs.cs_id) AND fp.par_value='1')");} + static void options(Mapd,String key,List> options){var f=d.get(key);d.put(key,new Definition(f.key(),f.label(),"select",f.group(),f.sql(),options));} + static List> meta(DatabaseService db,String type){return db.queryForList("SELECT mt_sort AS value,CONCAT_WS(' · ',NULLIF(mt_value,''),NULLIF(mt_mapped_value,'')) AS label FROM metatype WHERE mt_type=? ORDER BY mt_sort,mt_value",type);} + static LinkedHashMap definitions(DatabaseService db,MasterListColumns.Kind kind){ + var d=new LinkedHashMap();boolean cs=kind==MasterListColumns.Kind.customers; + flag(d,"onlyHidden","Nur ausgeblendete","Status","cmp.cmp_visible=0");flag(d,"authenticated","Nur freigegebene","Status","cmp.cmp_authenticated=1"); + for(String[] f:new String[][]{{"cmp_iln","ILN"},{"cmp_tax_idno","Steuernummer"},{"cmp_bank","Bank"},{"cmp_bankno","Bankleitzahl / BIC"},{"cmp_bankacc","Kontonummer / IBAN"},{"cmp_comp2","Firma 2"}})add(d,f[0],f[1],"text","Weitere Stammdaten","cmp."+f[0]); + if(cs){ + flag(d,"oldEid","Nur mit alter EID","Status",MasterDirectory.generic("cs","cs.cs_id","eid_old")+"<>''"); + flag(d,"noMeta","Ohne Metakundenreferenz","Status","COALESCE(cs.cs_id_parent,0)=0");flag(d,"withMeta","Mit Metakundenreferenz","Status","cs.cs_id_parent>0");flag(d,"discount","Mit Rabatt","Status","cs.cs_discount<>0"); + add(d,"cmp_type","Kundentyp","number","Status","cmp.cmp_type");options(d,"cmp_type",meta(db,"customer_type")); + add(d,"cmp_new_date","Neukunden seit","date","Status","cmp.cmp_new_date"); + add(d,"br_id","Branche","number","Status","cmp.br_id");options(d,"br_id",db.queryForList("SELECT br_id AS value,CONCAT_WS(' · ',br_key,br_name) AS label FROM branch ORDER BY br_key,br_name")); + add(d,"cmp_sales_ranking_man","Vertriebsranking","number","Status","cmp.cmp_sales_ranking_man");options(d,"cmp_sales_ranking_man",List.of(fields("value",1,"label","A"),fields("value",2,"label","B"),fields("value",3,"label","C"))); + for(String[] f:new String[][]{ + {"cs_jbstatusmail","E-Mail bei Erledigung"},{"cs_jbstatusmail3","E-Mail bei Erfassung"},{"cs_jbstatusmail2","E-Mail bei Abholung"},{"cs_jbstatusmail_price","Preis in E-Mail"},{"cs_jbstatusmail_emp","Mitarbeiterdaten bei Erledigung"},{"cs_jbstatusmail_pdf","PDF-Anhang"},{"cs_tracking","Sendungsverfolgung Endkunde"},{"cs_jbedit","Kundenseitige Auftragserfassung"},{"cs_ar","Automatische Vermittlung KAE"},{"cs_joblist_disabled","Aufträge in Listen ausblenden"},{"cs_markup","Treibstoffaufschlag aktiv"},{"cs_jb_commission_no","Rechnungskommission"},{"cs_commission_no","Kommissionsnummer"},{"cs_jobbatch","Listenkunde"},{"cs_cr_fav_only","Nur Anforderungen"},{"cs_invtext_mode","Rechnungskurztext"},{"cmp_no_longhaul","Keine bundesweite Vermittlung"},{"cmp_newsletter","Newsletter"},{"cmp_dsgvo","DSGVO"} + })flag(d,f[0],f[1],"Erfassung und Versand",(f[0].startsWith("cmp_")?"cmp.":"cs.")+f[0]+"=1"); + flag(d,"cs_jbstatusmail2csc","Mailadresse aus Kundenstamm","Erfassung und Versand","cs.cs_jbstatusmail2csc=0");flag(d,"cs_jbstatusmail2csc2","Mailadresse aus Bezahler-Kostenstelle","Erfassung und Versand","cs.cs_jbstatusmail2csc=1"); + for(String[] f:new String[][]{{"1","Etappenbemerkung"},{"3","Rechnungstext"},{"4","Zusatzmail nach Bearbeitung erledigter Aufträge"},{"5","Zusatzmail nach Erledigung einer Station"},{"7","KFZ-Kennzeichen"},{"8","Mail nach Storno"},{"9","Bemerkung Kundenmail Kurier"},{"10","Bemerkung Kundenmail Innendienst"}})flag(d,"mailField"+f[0],f[1],"Mailinhalt","SUBSTRING(cs.cs_jbstatusmail_fields,"+f[0]+",1)='1'"); + flag(d,"cs_jb_jam_waittime","Mail bei Vermittlungsstau","Erfassung und Versand","cs.cs_jb_jam_waittime>0");flag(d,"cs_jb_jam_waittime_minutes","Wartezeit für Vermittlungsstau hinterlegt","Erfassung und Versand","cs.cs_jb_jam_waittime_minutes>0"); + flag(d,"cmp_sustainability_email","Nachhaltigkeits-E-Mail hinterlegt","Abrechnung und Nachhaltigkeit","cmp.cmp_sustainability_email<>''");flag(d,"cmp_hqlino","Eigene Lieferantennummer hinterlegt","Abrechnung und Nachhaltigkeit","cmp.cmp_hqlino<>''"); + for(String[] f:new String[][]{{"cmp_sustainability_interval","Reportintervall"},{"cs_charging_time","Ladedauer (Minuten)"},{"cs_blur_markup_time","Unschärfeaufschlag (Minuten)"},{"cs_markup_prov","Treibstoffaufschlag (%)"},{"cs_payment_term","Zahlungsbedingung"},{"cs_invmode","Abrechnung"},{"cmp_inv2hq","Einzelrechnung (0/1)"}})add(d,f[0],f[1],"number","Abrechnung und Nachhaltigkeit",(f[0].startsWith("cmp_")?"cmp.":"cs.")+f[0]); + options(d,"cmp_sustainability_interval",meta(db,"interval"));options(d,"cs_payment_term",meta(db,"payment_term"));options(d,"cs_invmode",List.of(fields("value",0,"label","PZM"),fields("value",1,"label","PLZ"),fields("value",2,"label","Bereich"),fields("value",3,"label","Radius"))); + for(String[] f:new String[][]{ + {"emailNoFooter","Keine Fußzeile in E-Mails","JOBDETAILS_EMAIL_NO_FOOTER_"},{"emailArrival","Ankunftszeit in E-Mails","JOB_MAIL_STATION_ARRIVAL_TIME_"},{"emailEnglish","E-Mails auf Englisch","JOBDETAILS_EMAIL_LANGUAGE_"},{"creatorDiscount","Selbsterfassungsrabatt","CUSTOMER_MASK_JB_CREATOR_DISCOUNT_"},{"customerCancellation","Kundenseitiges Storno","CUSTOMER_MASK_JOBLIST_CANCELLATION_ENABLED_"},{"customerDisposition","Kundenseitige Vermittlung an bevorzugte Transporteure","CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"},{"calculatorOnly","Nur Calculator-Preisberechnung","CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"},{"allAddresses","Adresse ungeprüft übernehmen","MASK_CUST_ALL_ADDR_ENABLED_"},{"commissionIncomplete","Kommission: Auftrag unfertig","CS_MASK_COMM_NO_B_"},{"commissionStation","Kommission an jeder Station","CS_MASK_COMM_NO_C_"},{"newIncomplete","Neue Aufträge automatisch unfertig","CUSTOMER_MASK_JB_INCOMPLETE_"},{"stationNumber","Stationsnummer in Detailansicht","JOBDETAILS_STATIONS_STATION_NUMBER_"},{"noPrice","Keine Preisausgabe in KAE","MASK_CS_DONT_MAKE_STANDARD_PRICE_"},{"cargoMandatory","Lademaße Pflichtfeld","RANKING_JB2CRVH_MEASURE_CS_MANDATORY_"} + })parameter(d,f[0],f[1],f[2]); + flag(d,"cargoData","Lademaße in KAE","Erfassung und Versand","EXISTS(SELECT 1 FROM parameter fp WHERE fp.par_key='RANKING_JB2CRVH_MEASURE_CS_IDS' AND fp.hq_id=cs.hq_id AND fp.emp_id=0 AND FIND_IN_SET(cs.cs_id,fp.par_value)>0)"); + for(String[] addr:new String[][]{{"service","Leistungsadresse","1"},{"invoice","Rechnungsadresse","2"},{"pickup","Abhol-/Lieferadresse","4"}})for(String[] f:new String[][]{{"street","Straße"},{"zipcode","PLZ"},{"city","Ort"}})add(d,addr[0]+"_"+f[0],f[1],"text",addr[1],addr[2]+":"+f[0]); + }else{ + flag(d,"contractorsOnly","Nur Unternehmer","Status","COALESCE(cr.cr_id_parent,0)=0");add(d,"device","VPA-Geräteinfo enthält","text","VPA und Anforderungen","cr.cr_device_info"); + for(String[] f:new String[][]{{"crvh_drivetype","Antriebsart","drivetype"},{"vht_id_inv","Rechnungsfahrzeugtyp","vehicletype"}}){add(d,f[0],f[1],"number","Fahrzeug",f[0].equals("vht_id_inv")?"cr.vht_id_inv":"cv."+f[0]);options(d,f[0],meta(db,f[2]));} + } + return d; + } + static List> publicDefinitions(Map d){return d.values().stream().map(f->(Map)fields("key",f.key(),"label",f.label(),"type",f.type(),"group",f.group(),"options",f.options())).toList();} + static BigDecimal number(String s){try{if(!s.matches("[0-9]{1,9}([.,][0-9]{1,4})?"))throw new IllegalArgumentException();return new BigDecimal(s.replace(',','.'));}catch(Exception e){throw bad("Bitte einen gültigen nichtnegativen Zahlenwert eingeben.");}} + static void apply(StringBuilder sql,List args,Map criteria,Map definitions,boolean prefix){ + if(criteria==null)return;if(criteria.size()>100||!definitions.keySet().containsAll(criteria.keySet()))throw bad("Unbekannter erweiterter Suchfilter."); + for(var e:criteria.entrySet()){ + String value=e.getValue();if(value==null||value.isBlank())continue;value=value.strip();Definition f=definitions.get(e.getKey()); + if(f.sql().matches("[124]:.*")){ + String[] addr=f.sql().split(":");sql.append(" AND EXISTS(SELECT 1 FROM costcenteraddress ca JOIN address fa ON fa.ad_id=ca.ad_id WHERE ca.csc_id=cs.csc_id AND ca.adt_id=? AND fa.ad_").append(addr[1]);args.add(Integer.parseInt(addr[0])); + if(addr[1].equals("street")){sql.append(" LIKE ? ESCAPE '!' ");args.add(EntryCostCenterSearch.term(value,false));}else{EntryCostCenterSearch.term(value,false);sql.append("=?");args.add(value);}sql.append(')');continue; + } + if(f.type().equals("checkbox")){if(!value.equals("1"))throw bad("Ungültiger Schalterwert.");sql.append(" AND (").append(f.sql()).append(')');continue;} + sql.append(" AND ").append(f.sql()); + switch(f.type()){ + case "number","select"->{var n=number(value);if(f.type().equals("select")&&f.options().stream().noneMatch(o->number(text(o,"value")).compareTo(n)==0))throw bad("Unbekannter Auswahlwert: "+f.label());sql.append("=?");args.add(n);} + case "date"->{try{sql.append(">=?");args.add(LocalDate.parse(value));}catch(Exception ex){throw bad("Ungültiges Datum.");}} + default->{sql.append(" LIKE ? ESCAPE '!'");args.add(EntryCostCenterSearch.term(value,prefix));} + } + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java b/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java new file mode 100644 index 0000000..0e94059 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterDirectoryScope.java @@ -0,0 +1,33 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** PHP getHeadquartersAccessStructure: mandator, employee vector and per-branch matrix. */ +final class MasterDirectoryScope { + record Access(List> branches,boolean root){} + static Access access(DatabaseService db,LegacyUser u,MasterListColumns.Kind kind){ + return access(db,u,kind.right()); + } + static Access access(DatabaseService db,LegacyUser u,int right){ + u.require(right); + var people=db.queryForList("SELECT e.emp_rights,e.emp_headquarters,h.hq_admin FROM employee e JOIN user u ON u.usr_id=e.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE e.emp_id=? AND u.usr_id=? AND u.usr_type=1 AND u.hq_id=?",u.employeeId(),u.userId(),u.originalHeadquartersId()); + if(people.size()!=1)throw denied();var person=people.getFirst();String rights=text(person,"emp_rights"); + 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()); + 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(); + if(allowed.stream().noneMatch(h->id(h,"id")==u.headquartersId()))throw denied();return new Access(allowed,root); + } + static List select(LegacyUser u,Access a,List requested){ + var selected=requested==null||requested.isEmpty()?List.of(u.headquartersId()):requested; + if(selected.size()>100||selected.stream().anyMatch(h->h==null||a.branches().stream().noneMatch(b->id(b,"id")==h)))throw denied(); + return selected.stream().distinct().sorted().toList(); + } + static AccessDeniedException denied(){return new AccessDeniedException("Keine Freigabe für diese Niederlassung oder Stammdatenliste.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java b/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java new file mode 100644 index 0000000..88e2100 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterDocuments.java @@ -0,0 +1,172 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Object-bound files in the authoritative PHP document tree. No arbitrary path/host is accepted. */ +@RestController +@RequestMapping("/api/master-documents/{kind}/{object}") +class MasterDocuments { + enum Kind { + customers("cs",0,"90b1a8efc903576bbb2d6e2a79b00a5e"),couriers("cr",1,"f697836ccbee1a08ff752f5510049f6e"),vehicles("crvh",1,"7bb0357ar00a5u89ob1e3c9b2d6e2a79"),orders("jb",4,"a547bc4b2a91355a3aba3ed0827cf6f2"),employees("emp",3,"yxj5fr3gz7uau378cf4gh2fcnh67i7gh"),general("",-1,""),transfer("transfer",-1,""); + final String type,directory;final int right;Kind(String type,int right,String directory){this.type=type;this.right=right;this.directory=directory;} + } + enum ImportFormat { ZIPCODE_NEIGHBOURS,ZONE,ZONEAGIO,RADIUSZONE,ZONE_JOBS,TOURS,DHL,TIMS,BMW,ALLERGO } + record Context(Kind kind,long object,long headquarters,long mandator,String prefix,String directory,String label,boolean upload,boolean download,boolean root,int maxAll,int maxObject,String rename,Set restricted,TransferPortalScope.Grant transfer){ + Context(Kind kind,long object,long headquarters,long mandator,String prefix,String directory,String label,boolean upload,boolean download,boolean root,int maxAll,int maxObject,String rename,Set restricted){this(kind,object,headquarters,mandator,prefix,directory,label,upload,download,root,maxAll,maxObject,rename,restricted,null);} + Context(Kind kind,long object,long headquarters,long mandator,String prefix,String directory,String label,boolean upload,boolean download,boolean root,int maxAll,int maxObject,String rename){this(kind,object,headquarters,mandator,prefix,directory,label,upload,download,root,maxAll,maxObject,rename,Set.of());} + } + record Command(String requestId,String action,String folder,String name,String revision,String data,String originalOperation){} + record Operation(String requestId,long user,Kind kind,long object,long headquarters,String action,String folder,String name,String target,String before,String after,String data,String backup,String source,String context,String status){} + static final int MAX_UPLOAD=4_000_000,MAX_DOWNLOAD=64_000_000; + static final Set EXTENSIONS=Set.of("pdf","png","jpg","jpeg","gif","webp","tif","tiff","bmp","txt","csv","tsv","xml","json","doc","docx","xls","xlsx","ppt","pptx","odt","ods","odp","zip","7z","msg","eml"); + final DatabaseService db;final BusinessSupport support;final DocumentStore store; + MasterDocuments(DatabaseService db,BusinessSupport support,DocumentStore store){this.db=db;this.support=support;this.store=store;} + @GetMapping Object list(Authentication a,@PathVariable Kind kind,@PathVariable long object,@RequestParam(defaultValue="")String folder){return view(ApiController.user(a),kind,object,folder);} + @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);} + 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){ + if(kind==Kind.transfer)return TransferPortalScope.context(this,u,object); + if(kind!=Kind.general)u.require(kind.right);else if(u.accountType()!=1||object!=u.headquartersId())throw MasterDirectoryScope.denied();if(object<=0)throw bad("Stammdatenobjekt fehlt."); + var actor=one(db.queryForList("SELECT e.emp_rights,h.hq_admin FROM employee e JOIN user u ON u.usr_id=e.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE e.emp_id=? AND u.usr_id=? AND u.usr_type=1 AND u.hq_id=?",u.employeeId(),u.userId(),u.originalHeadquartersId())); + String live=text(actor,"emp_rights");boolean root=id(actor,"hq_admin")==u.employeeId();if(kind!=Kind.general&&(live.length()<=kind.right||live.charAt(kind.right)!='1'))throw MasterDirectoryScope.denied(); + boolean upload=root||u.hasRight(22)&&live.length()>22&&live.charAt(22)=='1',download=root||u.hasRight(23)&&live.length()>23&&live.charAt(23)=='1';if(!upload&&!download)throw MasterDirectoryScope.denied(); + String sql=switch(kind){case transfer->throw MasterDirectoryScope.denied();case general->"SELECT hq_id AS id,'' AS prefix,hq_id,'Allgemeine Dokumente' AS label FROM headquarters WHERE hq_id=? AND hq_id=?";case orders->"SELECT jb_id AS id,CAST(jb_id AS CHAR) AS prefix,hq_id,CONCAT('Auftrag ',jb_id) AS label FROM job WHERE jb_id=? AND hq_id=?";case employees->"SELECT e.emp_id AS id,CAST(e.emp_id AS CHAR) AS prefix,u.hq_id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE e.emp_id=? AND u.hq_id=? AND u.usr_type=1";case customers->"SELECT cs.cs_id AS id,cs.cs_eid AS prefix,cs.hq_id,cmp.cmp_comp AS label FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.cs_id=? AND cs.hq_id=?";case couriers->"SELECT cr.cr_id AS id,cr.cr_eid AS prefix,cr.hq_id,cmp.cmp_comp AS label FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE cr.cr_id=? AND cr.hq_id=?";case vehicles->"SELECT cv.crvh_id AS id,CAST(cv.crvh_id AS CHAR) AS prefix,cr.hq_id,CONCAT_WS(' · ',cv.crvh_sid,cv.crvh_vh_sign) AS label FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cv.crvh_id=? AND cr.hq_id=?";}; + var objectRow=one(db.queryForList(sql,object,u.headquartersId()));long hq=id(objectRow,"hq_id");String prefix=kind==Kind.general?"":DocumentStore.component(text(objectRow,"prefix")); + var md=one(db.queryForList("SELECT m.md_id,m.md_doc_path FROM mandator m JOIN mandatorheadquarters mh ON mh.md_id=m.md_id WHERE mh.hq_id=?",hq));String path=text(md,"md_doc_path");String expected="../import/upload/";if(!path.startsWith(expected))throw bad("Der Mandanten-Dokumentpfad ist nicht freigegeben."); + String mdPath=path.substring(expected.length()).replaceAll("/+$","");safeFolder(mdPath); + if(db.queryForList("SELECT md_id FROM mandator WHERE TRIM(TRAILING '/' FROM md_doc_path)=? AND md_id<>?",path.replaceAll("/+$",""),id(md,"md_id")).size()>0)throw bad("Mehrere Mandanten verwenden denselben Dokumentpfad."); + if(kind==Kind.general){var restricted=new TreeSet();for(var k:Kind.values())if(k!=Kind.general&&k!=Kind.transfer)restricted.add(k.directory.split("/")[0]); + for(var p:db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND (hq_id=0 OR hq_id IN (SELECT hq_id FROM mandatorheadquarters WHERE md_id=?)) AND par_key IN ('DATATRANSFER_DIRECTORY_CS','DATATRANSFER_DIRECTORY_CR','DATATRANSFER_DIRECTORY_CRVH','DATATRANSFER_DIRECTORY_JB','DATATRANSFER_DIRECTORY_EMP')",id(md,"md_id"))){String v=text(p,"par_value").replaceAll("/+$","");safeFolder(v);if(!v.isEmpty())restricted.add(v.split("/")[0]);} + int max=limit(hq,"DATATRANSFER_MAX_ALL_FILES_DIRECTORY_",500);return new Context(kind,object,hq,id(md,"md_id"),"",mdPath,"Allgemeine Dokumente",upload,upload||download,root,max,max,parameter(hq,"DATATRANSFER_UPLOAD_PREFIX_RENAME",""),Set.copyOf(restricted));} + String dir=parameter(hq,"DATATRANSFER_DIRECTORY_"+kind.type.toUpperCase(Locale.ROOT),kind.directory).replaceAll("/+$","");safeFolder(dir);if(dir.isEmpty())throw bad("Objektverzeichnis fehlt."); + boolean hqPath=parameter(hq,"DATATRANSFER_HQ_PATH_"+kind.type.toUpperCase(Locale.ROOT)+"_ENABLED","0").equals("1"); + if(hqPath){String mnemonic=text(one(db.queryForList("SELECT hq_mnemonic FROM headquarters WHERE hq_id=?",hq)),"hq_mnemonic");dir+="/"+DocumentStore.component(mnemonic.isEmpty()?"MISC":mnemonic);} + // An EID is a filename namespace. Duplicate EIDs in the shared namespace are ambiguous. + if(kind==Kind.customers||kind==Kind.couriers){String table=kind==Kind.customers?"customer":"courier",col=kind.type+"_eid";var args=new ArrayList(List.of(prefix,object));String scope=hqPath?" AND hq_id=?":" AND hq_id IN (SELECT hq_id FROM mandatorheadquarters WHERE md_id=?)";args.add(hqPath?hq:id(md,"md_id"));if(!db.queryForList("SELECT "+kind.type+"_id FROM "+table+" WHERE "+col+"=? AND "+kind.type+"_id<>?"+scope,args.toArray()).isEmpty())throw bad("Die EID ist in der gemeinsamen Dokumentablage mehrfach vergeben.");} + return new Context(kind,object,hq,id(md,"md_id"),prefix,(mdPath.isEmpty()?"":mdPath+"/")+dir,text(objectRow,"label"),upload,download,root,limit(hq,"DATATRANSFER_MAX_ALL_FILES_DIRECTORY_"+kind.type.toUpperCase(Locale.ROOT),500),limit(hq,"DATATRANSFER_MAX_FILES_SPECIAL_"+kind.type.toUpperCase(Locale.ROOT),3),parameter(hq,"DATATRANSFER_UPLOAD_PREFIX_RENAME","")); + } + static String safeFolder(String folder){if(folder==null||folder.length()>1000)throw bad("Ungültiges Unterverzeichnis.");if(folder.isEmpty())return folder;for(String part:folder.split("/",-1))DocumentStore.component(part);return folder;} + static String join(String directory,String name){return directory.isEmpty()?name:name.isEmpty()?directory:directory+"/"+name;} + static String dir(Context c,String folder){safeFolder(folder);if(c.kind()==Kind.general&&!c.root()&&!folder.isEmpty()&&c.restricted().contains(folder.split("/")[0]))throw MasterDirectoryScope.denied();return join(c.directory(),folder);} + static boolean belongs(Context c,String name){return c.kind()==Kind.general||name.equals(c.prefix())||name.startsWith(c.prefix()+"_")||name.startsWith(c.prefix()+".");} + static String file(Context c,String folder,String name){DocumentStore.component(name);if(!belongs(c,name))throw bad("Die Datei gehört nicht zu diesem Stammdatensatz.");return join(dir(c,folder),name);} + static String revision(DocumentStore.Entry entry,byte[] bytes){return AdminSupport.revision(List.of(entry,DocumentStore.digest(bytes)));} + static String contextRevision(Context c){var scope=List.of(c.kind(),c.object(),c.headquarters(),c.mandator(),c.prefix(),c.directory());return AdminSupport.revision(c.transfer()==null?scope:List.of(scope,c.transfer()));} + List operations(LegacyUser u,Kind kind,long object){return db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngdoc' AND gdc_obj_id=? ORDER BY gdc_id DESC",object).stream().map(r->decode(text(r,"gdc_content"))).filter(o->o.user()==u.userId()&&o.kind()==kind&&o.headquarters()==u.headquartersId()).toList();} + Operation decode(String s){try{return support.json.readValue(s,Operation.class);}catch(Exception e){throw bad("Ein Dokumentvorgang ist nicht lesbar.");}} + // Presence checks read names only: selecting a payer must not download every document. + boolean hasFiles(LegacyUser u,Kind kind,long object){ + var c=context(u,kind,object); + if(!store.configured())return false; + boolean present; + try(var fs=store.open()){ + present=fs.exists(c.directory())&&fs.list(c.directory()).stream() + .anyMatch(e->!e.directory()&&!e.name().startsWith(".")&&belongs(c,e.name())); + } + var after=context(u,kind,object); + if(!contextRevision(after).equals(contextRevision(c)))throw MasterDirectoryScope.denied(); + return present; + } + // One directory listing per namespace for a page of customers; never read file contents. + Map customerPresence(LegacyUser u,Collection customers){ + var result=new LinkedHashMap();var contexts=new LinkedHashMap(); + for(long customer:customers){result.put(customer,false);try{contexts.put(customer,context(u,Kind.customers,customer));} + catch(org.springframework.security.access.AccessDeniedException denied){/* unavailable in this workspace */} + catch(org.springframework.web.server.ResponseStatusException failure){if(failure.getStatusCode().value()!=404)throw failure;}} + if(contexts.isEmpty()||!store.configured())return result; + var directories=new HashMap>(); + try(var fs=store.open()){ + for(var c:contexts.values()){ + var entries=directories.computeIfAbsent(c.directory(),path->fs.exists(path)?fs.list(path):List.of()); + boolean present=entries.stream().anyMatch(e->!e.directory()&&!e.name().startsWith(".")&&belongs(c,e.name())); + try{var after=context(u,Kind.customers,c.object());if(contextRevision(after).equals(contextRevision(c)))result.put(c.object(),present);} + catch(org.springframework.security.access.AccessDeniedException denied){/* revoked while listing */} + catch(org.springframework.web.server.ResponseStatusException failure){if(failure.getStatusCode().value()!=404)throw failure;} + } + } + return result; + } + 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;} + } + 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();} + Map importSource(LegacyUser u,Kind kind,long object,String folder,String name,String expected,ImportFormat format){importAccess(u,kind,format);DocumentStore.component(name);if(!name.contains("import_"+format+"_")||format==ImportFormat.ZONE&&name.contains("import_ZONE_JOBS_"))throw bad("Die Datei gehört nicht zum gewählten Importformat.");var c=context(u,kind,object);if(!c.download())throw MasterDirectoryScope.denied();byte[] bytes; + try(var fs=store.open()){var entry=fs.list(dir(c,folder)).stream().filter(e->e.name().equals(name)&&!e.directory()).findFirst().orElseThrow(MasterDocuments::conflict);bytes=fs.read(file(c,folder,name),1_000_000);if(!revision(entry,bytes).equals(expected))throw conflict();} + importAccess(u,kind,format);var fresh=context(u,kind,object);if(!fresh.download()||!contextRevision(fresh).equals(contextRevision(c)))throw MasterDirectoryScope.denied();dir(fresh,folder);String csv,encoding="UTF-8"; + try{csv=StandardCharsets.UTF_8.newDecoder().onMalformedInput(java.nio.charset.CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString();}catch(java.nio.charset.CharacterCodingException e){csv=new String(bytes,java.nio.charset.Charset.forName("windows-1252"));encoding="Windows-1252";} + return fields("csv",csv,"filename",name,"encoding",encoding); + } + static byte[] bytes(String encoded){try{if(encoded==null||encoded.length()>5_333_336)throw bad("Dateien dürfen höchstens 4 MB groß sein.");byte[] data=Base64.getDecoder().decode(encoded);if(data.length>MAX_UPLOAD)throw bad("Dateien dürfen höchstens 4 MB groß sein.");return data;}catch(IllegalArgumentException e){throw bad("Ungültiger Dateiinhalt.");}} + static String uploadName(Context c,String original){String name=DocumentStore.component(original);if(!c.rename().isBlank()){String[] rule=c.rename().split(",",-1);if(rule.length!=2||rule[0].isEmpty())throw bad("Die Dateiumbenennung ist nicht eindeutig konfiguriert.");name=DocumentStore.component(name.replace(rule[0],rule[1]));}String ext=name.contains(".")?name.substring(name.lastIndexOf('.')+1).toLowerCase(Locale.ROOT):"";if(!EXTENSIONS.contains(ext))throw bad("Dieser Dateityp ist für Dokumentuploads nicht freigegeben.");return DocumentStore.component(c.prefix().isEmpty()||name.startsWith(c.prefix()+"_")?name:c.prefix()+"_"+name);} + synchronized Object start(LegacyUser u,Kind kind,long object,Command cmd){return db.documentOperation(()->startLocked(u,kind,object,cmd));} + Object startLocked(LegacyUser u,Kind kind,long object,Command cmd){if(cmd==null)throw bad("Vorgang fehlt.");BusinessSupport.token(cmd.requestId());var c=context(u,kind,object);if(!c.upload())throw MasterDirectoryScope.denied();support.transactional("genericdatacontainer"); + var existing=operations(u,kind,object).stream().filter(o->o.requestId().equals(cmd.requestId())).findFirst(); + byte[] payload=cmd.action()!=null&&cmd.action().equals("UPLOAD")?bytes(cmd.data()):new byte[0];String hash=DocumentStore.digest(payload); + if(existing.isPresent()){var o=existing.get();if(!Objects.equals(o.action(),cmd.action())||!Objects.equals(o.folder(),cmd.folder())||!Objects.equals(o.name(),cmd.name())||cmd.action().equals("UPLOAD")&&!o.after().equals(hash)||cmd.action().equals("RESTORE")&&!o.source().endsWith("/"+cmd.originalOperation()+".bak"))throw bad("Vorgangskennung für andere Dokumentdaten verwendet.");return resumeLocked(u,kind,object,cmd.requestId());} + if(!Set.of("UPLOAD","REMOVE","RESTORE").contains(Objects.toString(cmd.action(),"")))throw bad("Unbekannter Dokumentvorgang.");String folder=safeFolder(cmd.folder()),name=DocumentStore.component(cmd.name()),target=cmd.action().equals("UPLOAD")&&(cmd.revision()==null||cmd.revision().isEmpty())?join(dir(c,folder),uploadName(c,name)):file(c,folder,name); + if(cmd.action().equals("UPLOAD")&&cmd.revision()!=null&&!cmd.revision().isEmpty()&&!EXTENSIONS.contains(name.substring(name.lastIndexOf('.')+1).toLowerCase(Locale.ROOT)))throw bad("Dieser Dateityp ist für Dokumentuploads nicht freigegeben."); + String before="",source=""; + try(var fs=store.open()){ + if(!fs.exists(dir(c,folder))&&!folder.isEmpty())throw bad("Das Unterverzeichnis existiert nicht mehr."); + if(cmd.action().equals("RESTORE")){String original=BusinessSupport.token(cmd.originalOperation());var old=operations(u,kind,object).stream().filter(o->o.requestId().equals(original)&&o.action().equals("REMOVE")&&o.status().equals("DONE")&&o.folder().equals(folder)&&o.name().equals(name)).findFirst().orElseThrow(()->bad("Entferntes Dokument nicht gefunden."));if(!old.context().equals(contextRevision(c)))throw bad("Die Dokumentzuordnung hat sich geändert.");source=old.backup();hash=old.before();if(!fs.exists(source)||!DocumentStore.digest(fs.read(source,MAX_DOWNLOAD)).equals(hash))throw bad("Die gesicherte Datei ist nicht mehr vorhanden oder wurde verändert.");if(fs.exists(target))throw conflict();} + else if(fs.exists(target)){var entry=fs.list(dir(c,folder)).stream().filter(e->!e.directory()&&(join(dir(c,folder),e.name())).equals(target)).findFirst().orElseThrow(()->bad("Dokument nicht gefunden."));byte[] current=fs.read(target,MAX_DOWNLOAD);if(!revision(entry,current).equals(cmd.revision()))throw conflict();before=DocumentStore.digest(current);} + else if(cmd.action().equals("REMOVE")||cmd.revision()!=null&&!cmd.revision().isEmpty())throw conflict(); + if(cmd.action().equals("UPLOAD")&&before.isEmpty()&&!c.root()&&fs.exists(dir(c,folder))){var entries=fs.list(dir(c,folder)).stream().filter(e->!e.name().startsWith(".")).toList();if(entries.size()>=c.maxAll()||entries.stream().filter(e->belongs(c,e.name())).count()>=c.maxObject())throw bad("Die maximale Dokumentanzahl ist erreicht. Bitte zuerst Dateien entfernen.");} + } + String backup=join(dir(c,folder),".ng-trash/"+cmd.requestId()+".bak"); + var op=new Operation(cmd.requestId(),u.userId(),kind,object,u.headquartersId(),cmd.action(),folder,name,target,before,hash,"",backup,source,contextRevision(c),"PENDING"); + db.documentReceiptTransaction(()->{if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngdoc' AND gdc_gen_fieldname=?",cmd.requestId()).isEmpty())throw bad("Vorgangskennung bereits vergeben.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngdoc","gdc_obj_id",object,"gdc_gen_fieldname",cmd.requestId(),"gdc_content",support.encode(op),"gdc_context","PENDING")); + if(cmd.action().equals("UPLOAD"))for(int offset=0,part=0;offset5_333_336)throw bad("Ungültige Upload-Zwischenspeicherung.");} + byte[] bytes=bytes(encoded.toString());if(!DocumentStore.digest(bytes).equals(op.after()))throw bad("Die gespeicherten Upload-Daten sind unvollständig.");return bytes; + } + void quota(Context c,DocumentStore.Connection fs,String directory){ + if(c.root()||!fs.exists(directory))return;var entries=fs.list(directory).stream().filter(e->!e.name().startsWith(".")).toList(); + if(entries.size()>=c.maxAll()||entries.stream().filter(e->belongs(c,e.name())).count()>=c.maxObject())throw bad("Die maximale Dokumentanzahl ist erreicht. Bitte zuerst Dateien entfernen."); + } + static RuntimeException conflict(){return new org.springframework.web.server.ResponseStatusException(HttpStatus.CONFLICT,"Die Datei wurde zwischenzeitlich geändert oder der Dateiname ist belegt. Bitte die Liste neu laden.");} + synchronized Object resume(LegacyUser u,Kind kind,long object,String token){return db.documentOperation(()->resumeLocked(u,kind,object,token));} + Object resumeLocked(LegacyUser u,Kind kind,long object,String token){BusinessSupport.token(token);var c=context(u,kind,object);if(!c.upload())throw MasterDirectoryScope.denied();var op=operations(u,kind,object).stream().filter(o->o.requestId().equals(token)).findFirst().orElseThrow(()->bad("Vorgang nicht gefunden."));if(!op.status().equals("PENDING"))return fields("completed",true);if(!op.context().equals(contextRevision(c)))throw bad("Die Dokumentzuordnung hat sich geändert. Der offene Vorgang benötigt eine Klärung.");support.transactional("genericdatacontainer"); + try(var fs=store.open()){ + String directory=dir(c,op.folder());if(!op.action().equals("REMOVE")&&op.before().isEmpty()&&!fs.exists(op.target()))quota(c,fs,directory);fs.mkdir(directory);fs.mkdir(join(directory,".ng-trash")); + if(!op.before().isEmpty()){ + if(!fs.exists(op.backup())){if(!fs.exists(op.target())||!DocumentStore.digest(fs.read(op.target(),MAX_DOWNLOAD)).equals(op.before()))throw conflict();fs.move(op.target(),op.backup());} + if(!DocumentStore.digest(fs.read(op.backup(),MAX_DOWNLOAD)).equals(op.before()))throw conflict(); + if(fs.exists(op.target())&&DocumentStore.digest(fs.read(op.target(),MAX_DOWNLOAD)).equals(op.before()))fs.remove(op.target()); + } + if(!op.action().equals("REMOVE")){ + if(fs.exists(op.target())){if(!DocumentStore.digest(fs.read(op.target(),MAX_DOWNLOAD)).equals(op.after()))throw conflict();} + else if(op.action().equals("RESTORE")){if(!fs.exists(op.source())||!DocumentStore.digest(fs.read(op.source(),MAX_DOWNLOAD)).equals(op.after()))throw conflict();fs.move(op.source(),op.target());} + else {String staged=join(directory,".ng-staging/"+op.requestId()+".bin");fs.mkdir(join(directory,".ng-staging"));if(fs.exists(staged)){if(!DocumentStore.digest(fs.read(staged,MAX_UPLOAD)).equals(op.after()))fs.remove(staged);}if(!fs.exists(staged))fs.create(staged,payload(op));fs.move(staged,op.target());} + }else if(fs.exists(op.target()))throw conflict(); + if(op.action().equals("UPLOAD")){String staged=join(directory,".ng-staging/"+op.requestId()+".bin");if(fs.exists(staged))fs.remove(staged);} + if(op.action().equals("RESTORE")&&fs.exists(op.source())){if(!DocumentStore.digest(fs.read(op.source(),MAX_DOWNLOAD)).equals(op.after()))throw conflict();fs.remove(op.source());} + long count=fs.list(directory).stream().filter(e->!e.directory()&&belongs(c,e.name())).count(); + var fresh=context(u,kind,object);if(!fresh.upload()||!contextRevision(fresh).equals(contextRevision(c)))throw MasterDirectoryScope.denied();dir(fresh,op.folder()); + String counterType=c.transfer()==null?kind.type:c.transfer().objectType().toLowerCase(Locale.ROOT);long counterObject=c.transfer()==null?object:c.transfer().object(); + db.documentReceiptTransaction(()->{var row=one(db.lockRows("SELECT gdc_id,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngdoc' AND gdc_obj_id=? AND gdc_gen_fieldname=?",object,token));if(text(row,"gdc_context").equals("DONE"))return null; + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngdocpart' AND gdc_gen_fieldname LIKE ? ESCAPE '!'",token+"!_%"); + var complete=new Operation(op.requestId(),op.user(),op.kind(),op.object(),op.headquarters(),op.action(),op.folder(),op.name(),op.target(),op.before(),op.after(),"",op.backup(),op.source(),op.context(),"DONE");db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context='DONE' WHERE gdc_id=?",support.encode(complete),id(row,"gdc_id")); + if(op.action().equals("RESTORE")){String original=op.source().substring(op.source().lastIndexOf('/')+1).replace(".bak","");var old=one(db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngdoc' AND gdc_gen_fieldname=?",original));var removed=decode(text(old,"gdc_content"));var restored=new Operation(removed.requestId(),removed.user(),removed.kind(),removed.object(),removed.headquarters(),removed.action(),removed.folder(),removed.name(),removed.target(),removed.before(),removed.after(),"",removed.backup(),removed.source(),removed.context(),"RESTORED");db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context='RESTORED' WHERE gdc_id=?",support.encode(restored),id(old,"gdc_id"));} + if(kind!=Kind.general&&!counterType.equals("ramp")){var counters=db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_gen_fieldname='num_of_documents'",counterType,counterObject);if(counters.isEmpty())db.insert("genericdatacontainer",fields("gdc_obj_type",counterType,"gdc_obj_id",counterObject,"gdc_gen_fieldname","num_of_documents","gdc_content",Long.toString(count),"gdc_context",""));else for(var counter:counters)db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_id=?",Long.toString(count),id(counter,"gdc_id"));} + support.audit(u,kind==Kind.orders?object:0,kind==Kind.customers?object:0,kind==Kind.couriers?object:0,op.action().equals("REMOVE")?183:97,"Dokument "+op.action()+" · "+kind.type+" #"+object+" · "+op.name(),token);return null;});return fields("completed",true); + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterEntryRules.java b/backend/src/main/java/de/stadtbote/votian/MasterEntryRules.java new file mode 100644 index 0000000..4e3564d --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterEntryRules.java @@ -0,0 +1,97 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Current customer_special/courier_special/courier_vehicle entry rules. */ +final class MasterEntryRules { + final String kind, key, prefix, generation; final boolean uniqueCourierCompany, automaticSimilarity; final List internalBookingTerms; final boolean editable, totalWeight, singleInvoice, singleReminder; + final int minimumIdentifier, maximumIdentifier, phoneMinimum; final BigDecimal provisionMinimum, provisionMaximum; + MasterEntryRules(String kind, java.util.function.Function local, java.util.function.Function global) { + this.kind=kind; boolean customer=kind.equals("customers"),courier=kind.equals("couriers"); + key=customer?"cs_eid":courier?"cr_eid":kind.equals("vehicles")?"crvh_sid":""; + prefix=key.isEmpty()?"":local.apply(customer?"CS_EID_PREFIX":courier?"CR_EID_PREFIX":"CR_SID_PREFIX"); + generation=customer||courier?local.apply(customer?"CS_EID_GENERATION":"CR_EID_GENERATION"):""; + editable=!(customer||courier)||local.apply(customer?"CS_EID_EDITABLE":"CR_EID_EDITABLE").equals("1"); + java.util.function.Function fallback=k->{String v=local.apply(k);return v.isEmpty()?global.apply(k):v;}; + uniqueCourierCompany=courier&&!global.apply("MASK_CR_CHK_CMP_EXIST_DISABLED").equals("1"); + automaticSimilarity=customer&&fallback.apply("MASK_CS_NEW_SIMILARITY_SEARCH").equals("1"); + String bookingTerms=customer?fallback.apply("MASK_CS_CHECK_INTERNAL_BOOKING_ACCOUNT"):""; + internalBookingTerms=bookingTerms.isEmpty()?List.of():List.of(bookingTerms.split(",",-1)); + minimumIdentifier=courier?length(fallback.apply("MASK_COURIER_EID_LENGTH"),0,10,"Mindestlänge der Kuriernummer"):0; + maximumIdentifier=courier?length(fallback.apply("MASK_COURIER_EID_MAX_LENGTH"),10,10,"Maximallänge der Kuriernummer"):10; + if(minimumIdentifier>maximumIdentifier)throw bad("Die Mindestlänge der Kuriernummer ist größer als die Maximallänge."); + phoneMinimum=customer?length(fallback.apply("CS_PHONE_MINIMUM_LENGTH"),0,20,"Mindestlänge der Telefonnummer"):0; + totalWeight=kind.equals("vehicles")&&fallback.apply("MASK_CRVH_TOTALWEIGHT_MANDATORY").equals("1"); + singleInvoice=customer&&global.apply("MASK_INV_EMAIL_SINGLE_ONLY").equals("1"); + singleReminder=customer&&global.apply("MASK_REMINDER_EMAIL_SINGLE_ONLY").equals("1"); + provisionMinimum=customer?range(fallback.apply("MASK_CS_PROV_RANGE_LOWER"),"Provisionsuntergrenze"):null; + provisionMaximum=customer?range(fallback.apply("MASK_CS_PROV_RANGE_UPPER"),"Provisionsobergrenze"):null; + if(provisionMinimum!=null&&provisionMaximum!=null&&provisionMinimum.compareTo(provisionMaximum)>0)throw bad("Die Provisionsuntergrenze ist größer als die Obergrenze."); + for(String s:List.of(prefix,generation))if(s.length()>10||s.chars().anyMatch(Character::isISOControl))throw bad("Die Nummernvorgabe der Niederlassung ist ungültig."); + } + static MasterEntryRules read(DatabaseService db,LegacyUser u,String kind){var c=new OrderEntryCatalog(db);return new MasterEntryRules(kind,k->c.parameter(u.headquartersId(),k),k->c.parameter(0,k));} + boolean canSuggest(long id){return id==0&&!generation.isEmpty()&&Set.of("customers","couriers").contains(kind);} + boolean readOnly(long id){return !editable&&!canSuggest(id);} + void defaults(long id,Map values){if(id==0&&!key.isEmpty()&&(!kind.equals("customers")||!generation.isEmpty()))values.put(key,prefix);} + List definitions(List fields,long id){return fields.stream().map(f->{ + String type=f.key().equals(key)&&readOnly(id)?"readonly":f.type(); + int max=f.key().equals(key)?maximumIdentifier:f.max(); + boolean required=f.required()||f.key().equals("crvh_totalweight")&&totalWeight; + return new MasterData.Field(f.key(),f.label(),f.group(),type,max,required,f.options()); + }).toList();} + Map identifier(long id){return fields("key",key,"canSuggest",canSuggest(id),"readOnly",readOnly(id));} + Map hints(long id){var out=new LinkedHashMap(); + if(!key.isEmpty()&&readOnly(id))out.put(key,"Die Niederlassung gibt diese Nummer nicht zur Bearbeitung frei."); + else if(kind.equals("couriers")&&(minimumIdentifier>0||maximumIdentifier<10))out.put(key,"Erlaubt: "+minimumIdentifier+" bis "+maximumIdentifier+" Zeichen."); + if(id==0&&uniqueCourierCompany)out.put("cmp_comp","Dieser Firmenname darf in der Niederlassung noch keinem Transporteur zugeordnet sein."); + if(phoneMinimum>0)out.put("usr_phone","Mindestens "+phoneMinimum+" Zeichen ohne Leerzeichen."); + if(singleInvoice)out.put("usr_inv_email","Nur eine Rechnungs-E-Mail-Adresse zulässig."); + if(singleReminder)out.put("usr_reminder_email","Nur eine Mahn-E-Mail-Adresse zulässig."); + if(totalWeight)out.put("crvh_totalweight","Ein Gesamtgewicht größer als 0 kg ist vorgeschrieben."); + if(provisionMinimum!=null||provisionMaximum!=null)out.put("cs_prov","Provision: 0 % oder "+(provisionMinimum==null?"größer als 0": "mindestens "+provisionMinimum.toPlainString())+" %"+(provisionMaximum==null?"":" und höchstens "+provisionMaximum.toPlainString()+" %")+"."); + return out; + } + void validate(long target,Map old,Map values){ + if(!key.isEmpty()){ + String value=text(values,key); + if(readOnly(target)&&!value.equals(text(old,key)))throw bad("Die Nummer ist für diese Niederlassung schreibgeschützt."); + if(value.length()maximumIdentifier)throw bad("Die Kuriernummer benötigt "+minimumIdentifier+" bis "+maximumIdentifier+" Zeichen."); + if(kind.equals("customers")&&!prefix.isEmpty()&&value.equals(prefix))throw bad("Bitte die Kundennummer nach dem Präfix vervollständigen oder einen Nummernvorschlag übernehmen."); + } + if(phoneMinimum>0&&text(values,"usr_phone").replaceAll("(?U)\\s","").length()0&&(provisionMinimum!=null&&p.compareTo(provisionMinimum)<0||provisionMaximum!=null&&p.compareTo(provisionMaximum)>0))throw bad("Die Provision liegt außerhalb der Niederlassungsvorgaben. "+hints(target).get("cs_prov")); + if(singleInvoice)single(text(values,"usr_inv_email"),"Rechnungs-E-Mail"); + if(singleReminder)single(text(values,"usr_reminder_email"),"Mahn-E-Mail"); + } + } + void validateCompany(DatabaseService db,LegacyUser u,long target,Map values){ + if(target==0&&uniqueCourierCompany&&!db.lockRows("SELECT cr.cr_id FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE cr.hq_id=? AND cmp.cmp_comp=?",u.headquartersId(),text(values,"cmp_comp")).isEmpty())throw bad("Der Firmenname ist in dieser Niederlassung bereits einem Transporteur zugeordnet. Bitte den vorhandenen Datensatz prüfen oder einen anderen Namen wählen."); + } + Map companyRules(){return fields("internalBookingTerms",internalBookingTerms,"automaticSimilarity",automaticSimilarity);} + Map suggestion(DatabaseService db){ + if(!canSuggest(0))throw bad("Für diese Neuanlage ist kein Nummernvorschlag eingerichtet."); + String table=kind.equals("customers")?"customer":"courier"; + // PHP uses the collation's MAX over the entire table, with an exclusive upper bound. + String last=text(db.queryForList("SELECT MAX("+key+") AS last_number FROM "+table+" WHERE "+key+"maximumIdentifier)throw bad("Der Nummernvorschlag passt nicht zur erlaubten Nummernlänge. Bitte die Nummernvorgaben prüfen."); + if(!db.queryForList("SELECT "+key+" FROM "+table+" WHERE "+key+"=? LIMIT 1",value).isEmpty())throw bad("Der aus den Bestandsnummern ermittelte Vorschlag ist bereits vergeben. Bitte die Nummernvorgaben prüfen."); + return fields("key",key,"value",value,"message","Die Nummer ist ein Vorschlag. Ihre Verfügbarkeit wird beim Speichern erneut geprüft."); + } + static String next(String previous){ + String letters=previous.replaceAll("[^a-zA-Z]",""),digits=previous.replaceAll("[^0-9]",""); + if(!previous.matches("[a-zA-Z0-9]*"))throw bad("Die bisherige höchste Nummer kann nicht eindeutig fortgesetzt werden."); + return letters+new BigInteger(digits.isEmpty()?"0":digits).add(BigInteger.ONE); + } + static int length(String s,int fallback,int max,String label){if(s.isEmpty())return fallback;try{int v=Integer.parseInt(s);if(v<0||v>max)throw new NumberFormatException();return v;}catch(NumberFormatException e){throw bad(label+": ungültige Niederlassungsvorgabe.");}} + static BigDecimal range(String s,String label){if(s.isEmpty())return null;try{var v=new BigDecimal(s.replace(',','.'));if(v.signum()<0||v.compareTo(BigDecimal.valueOf(100))>0)throw new NumberFormatException();return v;}catch(NumberFormatException e){throw bad(label+": ungültige Niederlassungsvorgabe.");}} + static void single(String value,String label){if(Arrays.stream(value.split("[;,]",-1)).filter(s->!s.isBlank()).count()>1)throw bad("Es ist nur eine "+label+"-Adresse zulässig.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportData.java b/backend/src/main/java/de/stadtbote/votian/MasterExportData.java new file mode 100644 index 0000000..e440f21 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportData.java @@ -0,0 +1,78 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.time.*; +import java.time.format.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** Current PHP categories 1/2/3; no database-supplied expressions are executed. */ +final class MasterExportData { + record Selection(List> raw,List> rows,List evidence,int skipped){} + final DatabaseService db; + MasterExportData(DatabaseService db){this.db=db;} + static final List PARAMETERS=List.of("ROW_EDIT_STATUS_NEW","ROW_EDIT_STATUS_MODIFY","ROW_EDIT_STATUS_DELETE","CMP_BANKMODE_INVOICE","CMP_BANKMODE_DEBITNOTE","EXPORT_FILTER_NO_DRIVER","EXPORT_FILTER_NO_CASHPAYER","EXPORT_FILTER_EID_LENGTH","EXPORT_HQ_KEY","CSC_ID_PAYER_CASH","MASK_VEHICLE_TYPE_MAPPING"); + Map parameters(long hq){ + var args=new ArrayList();args.add(hq);args.addAll(PARAMETERS); + var values=db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ("+String.join(",",Collections.nCopies(PARAMETERS.size(),"?"))+") ORDER BY hq_id,par_key",args.toArray()); + var result=new TreeMap();PARAMETERS.forEach(k->result.put(k,""));var seen=new HashSet(); + for(var v:values){String key=text(v,"par_key");if(!seen.add(id(v,"hq_id")+":"+key))throw bad("Mehrdeutiger Exportparameter: "+key);if(!text(v,"par_value").isBlank())result.put(key,text(v,"par_value"));}return result; + } + Selection select(LegacyUser u,int category,int status){ + if(!MasterExportFields.SQL.containsKey(category)||status<0||status>3)throw bad("Ungültige Exportkategorie oder Änderungsfilter."); + var settings=parameters(u.headquartersId());var columns=new TreeSet<>(MasterExportFields.SQL.get(category).values()); + String joins,scope,sort,state; + if(category==1){columns.add("cs.cs_id AS object_id");columns.add("cmp.cmp_export_time");joins="customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id JOIN employee emp ON emp.emp_id=cs.cs_admin JOIN user usr ON usr.usr_id=emp.usr_id JOIN address ad ON ad.ad_id=cmp.ad_id LEFT JOIN tax tx ON tx.tx_id=cmp.tx_id LEFT JOIN branch br ON br.br_id=cmp.br_id LEFT JOIN costcenter csc ON csc.csc_id=cs.csc_id AND csc.cs_id=cs.cs_id LEFT JOIN costcenteraddress cscad ON cscad.csc_id=csc.csc_id AND cscad.adt_id=2 LEFT JOIN address adre ON adre.ad_id=cscad.ad_id LEFT JOIN headquarters hq ON hq.hq_id=cs.hq_id";scope="cs.hq_id=? AND usr.hq_id=? AND cmp.cmp_type=0 AND cs.cs_eid<>''";sort="cmp.cmp_comp,cs.cs_id";state="cmp.cmp_modify_status";} + else if(category==2){columns.add("cr.cr_id AS object_id");columns.add("cmp.cmp_export_time");columns.add("cmp.cmp_contract_date");joins="courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN user usr ON usr.usr_id=cr.usr_id JOIN address ad ON ad.ad_id=cmp.ad_id";scope="cr.hq_id=? AND usr.hq_id=?";sort="cmp.cmp_comp,cr.cr_id";state="cmp.cmp_modify_status";} + else{columns.add("crvh.crvh_id AS object_id");columns.add("crvh.crvh_export_time");columns.add("cmp.cmp_contract_date");joins="couriervehicle crvh JOIN courier cr ON cr.cr_id=crvh.cr_id LEFT JOIN courier cr_parent ON cr_parent.cr_id=cr.cr_id_parent AND cr_parent.hq_id=cr.hq_id LEFT JOIN company cmp ON cmp.cmp_id=cr.cmp_id";scope="cr.hq_id=?";sort="crvh.crvh_sid,crvh.crvh_id";state="crvh.crvh_modify_status";} + var args=new ArrayList();args.add(u.headquartersId());if(category!=3)args.add(u.headquartersId()); + if(status==1||status==2){scope+=" AND "+state+"=?";args.add(status);}else if(status==3)scope+=" AND "+state+" IN ("+(category==3?"1,2,3":"1,2")+")"; + if(category==1){if(settings.get("EXPORT_FILTER_NO_CASHPAYER").equals("1")&&settings.get("CSC_ID_PAYER_CASH").matches("[0-9]+")){scope+=" AND (cs.csc_id_payer IS NULL OR cs.csc_id_payer<>?)";args.add(settings.get("CSC_ID_PAYER_CASH"));}String length=settings.get("EXPORT_FILTER_EID_LENGTH");if(!length.isBlank()){if(!length.matches("[0-9]{1,4}"))throw bad("Die EID-Längengrenze ist ungültig.");if(Integer.parseInt(length)>0){scope+=" AND LENGTH(TRIM(cs.cs_eid))<=?";args.add(Integer.parseInt(length));}}} + if(category==2&&settings.get("EXPORT_FILTER_NO_DRIVER").equals("1"))scope+=" AND COALESCE(cr.cr_id_parent,0)=0"; + var found=db.queryForList("SELECT "+String.join(",",columns)+" FROM "+joins+" WHERE "+scope+" ORDER BY "+sort+" LIMIT 10001",args.toArray());if(found.size()>10000)throw bad("Mehr als 10.000 Stammdatensätze. Bitte den Änderungsfilter eingrenzen."); + if(found.stream().map(r->id(r,"object_id")).distinct().count()!=found.size())throw bad("Mehrdeutige Stammdaten- oder Rechnungsadresszuordnung. Bitte zuerst bereinigen."); + var raw=new ArrayList>();var rows=new ArrayList>();var evidence=new ArrayList();evidence.add(settings);LocalDate today=LocalDate.now(ZoneId.of("Europe/Berlin"));evidence.add(today);int skipped=0;long truck=7; + if(category==3){for(String item:settings.get("MASK_VEHICLE_TYPE_MAPPING").split("\\|")){var pair=item.split("=",-1);if(pair.length==2&&pair[0].equals("truck_01")&&pair[1].matches("[0-9]{1,9}")){var types=db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",Long.parseLong(pair[1]));evidence.add(types);if(types.size()==1)truck=Long.parseLong(pair[1]);}}} + + var contracts=category==1?Map.of():meta(3,1631,found.stream().filter(r->emptyDate(text(r,"cmp_contract_date"))).map(r->id(r,"cr_id")).distinct().toList(),evidence); + var deadlines=category==3?meta(4,1728,found.stream().map(r->id(r,"crvh_id")).toList(),evidence):Map.of(); + var terms=new HashMap();if(category==1&&!found.isEmpty()){ + var termIds=found.stream().map(r->id(r,"cs_payment_term")).distinct().sorted().toList(); + var values=db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='payment_term' AND mt_sort IN ("+String.join(",",Collections.nCopies(termIds.size(),"?"))+") ORDER BY mt_sort,mt_value",termIds.toArray());evidence.add(values); + for(var v:values)if(terms.put(id(v,"mt_sort"),text(v,"mt_value"))!=null)throw bad("Mehrdeutiges Zahlungsziel im Export."); + } + for(var row:found){ + if(category!=1){String contract=text(row,"cmp_contract_date");if(emptyDate(contract))contract=contracts.getOrDefault(id(row,"cr_id"),"");LocalDate start=date(contract);if(start!=null&&start.isAfter(today)){skipped++;continue;} + if(category==3){LocalDate end=date(deadlines.getOrDefault(id(row,"crvh_id"),""));if(end!=null&&!today.isBefore(end)){skipped++;continue;}} + } + var v=new LinkedHashMap(row);transform(v,category,settings);if(category==3){long type=id(row,"vht_id");v.put("vht_id",u.headquartersId()<100&&type==999||type meta(int category,long key,List objects,List evidence){ + if(objects.isEmpty())return Map.of(); + var definitions=db.queryForList("SELECT ck.mtfck_id FROM metafieldcategorykey ck WHERE ck.mtfc_id=? AND ck.mtfk_id=? AND ck.hq_id=0 ORDER BY ck.mtfck_id",category,key);evidence.add(definitions); + if(definitions.size()>1)throw bad("Mehrdeutige Exportdatums-Vorgabe im Zusatzformular.");if(definitions.isEmpty())return Map.of(); + var args=new ArrayList();args.add(id(definitions.getFirst(),"mtfck_id"));args.addAll(objects); + var values=db.queryForList("SELECT v.mtfv_id,v.mtfv_value FROM metafieldvalue v WHERE v.mtfck_id=? AND v.mtfv_id IN ("+String.join(",",Collections.nCopies(objects.size(),"?"))+") ORDER BY v.mtfv_id,v.mtfv_value",args.toArray());evidence.add(values); + var result=new HashMap();for(var v:values)if(result.put(id(v,"mtfv_id"),text(v,"mtfv_value"))!=null)throw bad("Mehrdeutige Exportdatums-Vorgabe im Zusatzformular.");return result; + } + static boolean emptyDate(String s){return s.isBlank()||s.equals("ERR")||s.startsWith("0000-00-00");} + static LocalDate date(String s){if(emptyDate(s))return null;try{if(s.matches("[0-9]{2}\\.[0-9]{2}\\.[0-9]{4}"))return LocalDate.parse(s,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT));return LocalDate.parse(s.substring(0,10));}catch(RuntimeException e){throw bad("Ein Vertrags-/Exportdatum ist ungültig. Bitte die Stammdaten prüfen.");}} + static void transform(Map v,int category,Map p){ + for(String key:new ArrayList<>(v.keySet()))if(MasterExportFields.type(key).equals("datetime")){String value=text(v,key);v.put(key,emptyDate(value)?"":value.length()==10?date(value).atStartOfDay().toString():value.replace(' ','T'));} + v.put("cmp_no",p.get("EXPORT_HQ_KEY")); + if(category!=3){long status=id(v,"cmp_modify_status");v.put("cmp_modify_status",status==1?p.get("ROW_EDIT_STATUS_NEW"):status==2?p.get("ROW_EDIT_STATUS_MODIFY"):status==0?" ":Long.toString(status));long bank=id(v,"cmp_bankmode");v.put("cmp_bankmode",bank==0?p.get("CMP_BANKMODE_INVOICE"):bank==1?p.get("CMP_BANKMODE_DEBITNOTE"):Long.toString(bank)); + for(var pair:Map.of("usr_name","Name","usr_firstname","Vorname").entrySet())if(text(v,pair.getKey()).strip().equals(pair.getValue())||text(v,pair.getKey()).strip().matches("[+-]?[0-9]+(?:[.,][0-9]+)?"))v.put(pair.getKey()," ".repeat(pair.getKey().equals("usr_name")?20:15)); + } + if(category==1){long tax=id(v,"tx_id");v.put("tx_id",tax==2?"03":tax==3?"04":tax==5?"09":"01");int interval=(int)id(v,"csc_inv_interval");v.put("csc_inv_interval",List.of("9F","9W","9U","9T","9V").get(interval>=0&&interval<=4?interval:0));for(String key:List.of("cmp_dsgvo","cs_jbedit"))v.put(key,id(v,key)==1?"JA":"NEIN"); + if(List.of("adre_street","cscad_hsno","adre_zipcode","adre_city").stream().anyMatch(k->text(v,k).isBlank())){for(String part:List.of("street","zipcode","city","country"))v.put("adre_"+part,v.get("ad_"+part));v.put("cscad_hsno",v.get("cmp_hsno"));} + if(text(v,"cscad_comp").isBlank()){v.put("cscad_comp",v.get("cmp_comp"));v.put("cscad_comp2",v.get("cmp_comp2"));} + }else if(category==2)v.put("cmp_type",id(v,"cmp_type")==1?"X":" "); + else{long status=id(v,"crvh_modify_status");v.put("crvh_modify_status",status==3?p.get("ROW_EDIT_STATUS_DELETE"):" ");v.put("crvh_special",id(v,"crvh_special")==1?"X":" ");long ci=id(v,"crvh_corporate_identity");char[] chars=("G "+" ".repeat(58)).toCharArray();if((ci&15)==1||(ci&15)==3)chars[0]='X';if(((ci>>4)&15)==1||((ci>>4)&15)==3)chars[2]='X';v.put("crvh_corporate_identity",new String(chars));if(!text(v,"cr_eid_parent").isBlank())v.put("cr_eid",v.get("cr_eid_parent"));} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportFields.java b/backend/src/main/java/de/stadtbote/votian/MasterExportFields.java new file mode 100644 index 0000000..1d3c711 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportFields.java @@ -0,0 +1,12 @@ +package de.stadtbote.votian; +import java.util.*; +/** Explicit projection whitelist, verified against the current server export catalog (18 October 2026). */ +final class MasterExportFields { + static final Map> SQL=Map.of( +1,Map.ofEntries(Map.entry("cmp.cmp_id","cmp.cmp_id"),Map.entry("cmp.cmp_comp","cmp.cmp_comp"),Map.entry("cmp.cmp_comp2","cmp.cmp_comp2"),Map.entry("cmp.cmp_iln","cmp.cmp_iln"),Map.entry("cmp.cmp_tax_idno","cmp.cmp_tax_idno"),Map.entry("cmp.cmp_bank","cmp.cmp_bank"),Map.entry("cmp.cmp_bankno","cmp.cmp_bankno"),Map.entry("cmp.cmp_bankacc","cmp.cmp_bankacc"),Map.entry("cs.cs_discount","cs.cs_discount"),Map.entry("usr.usr_name","usr.usr_name"),Map.entry("usr.usr_firstname","usr.usr_firstname"),Map.entry("usr.usr_email","usr.usr_email"),Map.entry("usr.usr_phone","usr.usr_phone"),Map.entry("usr.usr_phone2","usr.usr_phone2"),Map.entry("usr.usr_fax","usr.usr_fax"),Map.entry("cs.cs_invmode","cs.cs_invmode"),Map.entry("ad.ad_street","ad.ad_street"),Map.entry("cmp.cmp_hsno","cmp.cmp_hsno"),Map.entry("ad.ad_zipcode","ad.ad_zipcode"),Map.entry("ad.ad_city","ad.ad_city"),Map.entry("ad.ad_country","ad.ad_country"),Map.entry("cs.cs_eid","cs.cs_eid"),Map.entry("cmp.cmp_modify_status","cmp.cmp_modify_status"),Map.entry("cmp.cmp_new_date","cmp.cmp_new_date"),Map.entry("cmp.cmp_bankmode","cmp.cmp_bankmode"),Map.entry("cmp.cmp_postage","cmp.cmp_postage"),Map.entry("cmp.cmp_remark","cmp.cmp_remark"),Map.entry("cmp.tx_id","cmp.tx_id"),Map.entry("cmp.cmp_match","cmp.cmp_match"),Map.entry("cmp.cmp_match2","cmp.cmp_match2"),Map.entry("br.br_key","br.br_key"),Map.entry("br.br_name","br.br_name"),Map.entry("adre.adre_street","adre.ad_street AS adre_street"),Map.entry("cscad.cscad_hsno","cscad.cscad_hsno"),Map.entry("adre.adre_zipcode","adre.ad_zipcode AS adre_zipcode"),Map.entry("adre.adre_city","adre.ad_city AS adre_city"),Map.entry("adre.adre_country","adre.ad_country AS adre_country"),Map.entry("cscad.cscad_comp","cscad.cscad_comp"),Map.entry("cscad.cscad_comp2","cscad.cscad_comp2"),Map.entry("csc.csc_inv_interval","csc.csc_inv_interval"),Map.entry("inv.const_01","'C01' AS const_01"),Map.entry("inv.const_02","'C02' AS const_02"),Map.entry("inv.const_03","'C03' AS const_03"),Map.entry("inv.cmp_no","'CMP' AS cmp_no"),Map.entry("cs.cs_prov","cs.cs_prov"),Map.entry("cmp.cmp_stax_idno","cmp.cmp_stax_idno"),Map.entry("cmp.cmp_iban","cmp.cmp_iban"),Map.entry("cmp.cmp_swift","cmp.cmp_swift"),Map.entry("cmp.cmp_hqlino","cmp.cmp_hqlino"),Map.entry("cmp.cmp_comp3","cmp.cmp_comp3"),Map.entry("cmp.cmp_comp4","cmp.cmp_comp4"),Map.entry("cscad.cscad_comp3","cscad.cscad_comp3"),Map.entry("cscad.cscad_comp4","cscad.cscad_comp4"),Map.entry("cscad.cscad_email","cscad.cscad_email"),Map.entry("usr.usr_inv_email","usr.usr_inv_email"),Map.entry("cmp.cmp_dsgvo","cmp.cmp_dsgvo"),Map.entry("cs.cs_jbedit","cs.cs_jbedit"),Map.entry("hq.hq_mnemonic","hq.hq_mnemonic"),Map.entry("cs.cs_payment_term","cs.cs_payment_term"),Map.entry("usr.usr_reminder_email","usr.usr_reminder_email")), +2,Map.ofEntries(Map.entry("cmp.cmp_comp","cmp.cmp_comp"),Map.entry("cmp.cmp_comp2","cmp.cmp_comp2"),Map.entry("cmp.cmp_iln","cmp.cmp_iln"),Map.entry("cmp.cmp_tax_idno","cmp.cmp_tax_idno"),Map.entry("cmp.cmp_bank","cmp.cmp_bank"),Map.entry("cmp.cmp_bankno","cmp.cmp_bankno"),Map.entry("cmp.cmp_bankacc","cmp.cmp_bankacc"),Map.entry("cmp.cmp_bankmode","cmp.cmp_bankmode"),Map.entry("cmp.cmp_modify_status","cmp.cmp_modify_status"),Map.entry("cmp.cmp_new_date","cmp.cmp_new_date"),Map.entry("cr.cr_maxweight","cr.cr_maxweight"),Map.entry("cr.cr_outlay","cr.cr_outlay"),Map.entry("usr.usr_name","usr.usr_name"),Map.entry("usr.usr_firstname","usr.usr_firstname"),Map.entry("usr.usr_email","usr.usr_email"),Map.entry("usr.usr_phone","usr.usr_phone"),Map.entry("usr.usr_phone2","usr.usr_phone2"),Map.entry("usr.usr_fax","usr.usr_fax"),Map.entry("ad.ad_street","ad.ad_street"),Map.entry("cmp.cmp_hsno","cmp.cmp_hsno"),Map.entry("ad.ad_zipcode","ad.ad_zipcode"),Map.entry("ad.ad_city","ad.ad_city"),Map.entry("ad.ad_country","ad.ad_country"),Map.entry("cmp.cmp_postage","cmp.cmp_postage"),Map.entry("cmp.cmp_remark","cmp.cmp_remark"),Map.entry("cr.cr_eid","cr.cr_eid"),Map.entry("cmp.cmp_id","cmp.cmp_id"),Map.entry("inv.const_01","'C01' AS const_01"),Map.entry("inv.const_02","'C02' AS const_02"),Map.entry("inv.const_03","'C03' AS const_03"),Map.entry("inv.cmp_no","'CMP' AS cmp_no"),Map.entry("cmp.cmp_stax_idno","cmp.cmp_stax_idno"),Map.entry("cmp.cmp_iban","cmp.cmp_iban"),Map.entry("cmp.cmp_swift","cmp.cmp_swift"),Map.entry("usr.usr_inv_email","usr.usr_inv_email"),Map.entry("cmp.cmp_type","cmp.cmp_type"),Map.entry("cr.cr_id","cr.cr_id")), +3,Map.ofEntries(Map.entry("crvh.crvh_id","crvh.crvh_id"),Map.entry("crvh.vht_id","crvh.vht_id"),Map.entry("crvh.crvh_sid","crvh.crvh_sid"),Map.entry("crvh.crvh_vh_sign","crvh.crvh_vh_sign"),Map.entry("crvh.crvh_vh_name","crvh.crvh_vh_name"),Map.entry("crvh.crvh_licence_date","crvh.crvh_licence_date"),Map.entry("crvh.crvh_owner","crvh.crvh_owner"),Map.entry("crvh.crvh_prov","crvh.crvh_prov"),Map.entry("crvh.crvh_payload","crvh.crvh_payload"),Map.entry("crvh.crvh_totalweight","crvh.crvh_totalweight"),Map.entry("crvh.crvh_mobile","crvh.crvh_mobile"),Map.entry("crvh.crvh_insurance_name","crvh.crvh_insurance_name"),Map.entry("crvh.crvh_insurance_no","crvh.crvh_insurance_no"),Map.entry("crvh.crvh_remark","crvh.crvh_remark"),Map.entry("crvh.crvh_modify_status","crvh.crvh_modify_status"),Map.entry("crvh.cr_id","crvh.cr_id"),Map.entry("cr.cr_eid","cr.cr_eid"),Map.entry("cr.cr_maxweight","cr.cr_maxweight"),Map.entry("cr.cr_outlay","cr.cr_outlay"),Map.entry("cr.cr_imei","cr.cr_imei"),Map.entry("cr.cr_mobile_pda","cr.cr_mobile_pda"),Map.entry("crvh.crvh_comm_no","crvh.crvh_comm_no"),Map.entry("cr_parent.cr_eid_parent","cr_parent.cr_eid AS cr_eid_parent"),Map.entry("cr_parent.cr_sid_parent","cr_parent.cr_sid AS cr_sid_parent"),Map.entry("inv.const_01","'C01' AS const_01"),Map.entry("inv.const_02","'C02' AS const_02"),Map.entry("inv.const_03","'C03' AS const_03"),Map.entry("inv.cmp_no","'CMP' AS cmp_no"),Map.entry("crvh.crvh_special","crvh.crvh_special"),Map.entry("crvh.crvh_corporate_identity","crvh.crvh_corporate_identity"),Map.entry("crvh.crvh_advertising_allowance","crvh.crvh_advertising_allowance"),Map.entry("crvh.crvh_partner_premium","crvh.crvh_partner_premium"),Map.entry("crvh.crvh_transport_insurance_amount","crvh.crvh_transport_insurance_amount"),Map.entry("crvh.crvh_system_fee_amount","crvh.crvh_system_fee_amount"),Map.entry("crvh.crvh_business_liability_insurance_amount","crvh.crvh_business_liability_insurance_amount"),Map.entry("crvh.crvh_business_liability_insurance_date","crvh.crvh_business_liability_insurance_date"),Map.entry("crvh.crvh_partner_commission","crvh.crvh_partner_commission"))); + static String key(String expression){return expression.substring(expression.indexOf('.')+1);} + static String type(String key){if(key.endsWith("_date")||key.equals("crvh_owner"))return "datetime";return Set.of("cmp_postage","cs_discount","cs_prov","cr_outlay","crvh_prov","crvh_advertising_allowance","crvh_partner_premium","crvh_transport_insurance_amount","crvh_system_fee_amount","crvh_business_liability_insurance_amount","crvh_partner_commission").contains(key)?"float":"text";} + private MasterExportFields(){} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java b/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java new file mode 100644 index 0000000..2f1b37f --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportFiles.java @@ -0,0 +1,26 @@ +package de.stadtbote.votian; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import org.springframework.http.ResponseEntity; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; + +/** The existing central PHP export directory, scoped to headquarters and export rights. */ +@RestController +@RequestMapping("/api/data-transfer/master-export-files") +class MasterExportFiles { + final MasterExports exports;final CustomerExportFiles files;final CustomerExportRemoval removal; + MasterExportFiles(MasterExports exports,ExportFileStore store){this.exports=exports;files=new CustomerExportFiles(exports.db,store){ + @Override Access access(LegacyUser u){exports.rights(u);String path=new OrderEntryCatalog(db).parameter(0,"EXPORT_PATH");if(!path.isBlank()&&!path.replaceAll("/+$","").equals("../export/download"))throw bad("Die konfigurierte PHP-Exportablage muss abgeglichen werden.");return new Access(0,true);} + @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("/{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/{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);} + @PostMapping("/removal/{requestId}/restore") Object restore(Authentication a,@PathVariable String requestId){return removal.restore(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 new file mode 100644 index 0000000..2a7c2b8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportFormat.java @@ -0,0 +1,32 @@ +package de.stadtbote.votian; +import java.util.*; +import static de.stadtbote.votian.CustomerExportFormat.*; +import static de.stadtbote.votian.AdminSupport.bad; +final class MasterExportFormat { + static byte[] bytes(String content){try{if(content.indexOf('\0')>=0)throw bad("Die Exportdatei enthält ein ungültiges Nullzeichen.");var buffer=java.nio.charset.Charset.forName("windows-1252").newEncoder().onMalformedInput(java.nio.charset.CodingErrorAction.REPORT).onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT).encode(java.nio.CharBuffer.wrap(content));byte[] result=new byte[buffer.remaining()];buffer.get(result);return result;}catch(java.nio.charset.CharacterCodingException e){throw bad("Ein Exportwert enthält Zeichen, die das alte Windows-1252-Dateiformat nicht darstellen kann. Bitte die Stammdaten prüfen.");}} + + 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(); + 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())); + if(condition(r.condition(),row,fields)){v=format(v,f.type(),r.format());if(r.from()!=null||r.to()!=null){if(r.leftRight()){int left=Objects.requireNonNullElse(r.from(),0),right=Objects.requireNonNullElse(r.to(),0);v=substring(v,0,left).strip()+substring(v,Math.max(0,v.length()-right),v.length()).strip();}else v=substring(v,Objects.requireNonNullElse(r.from(),0),r.to()==null?v.length():r.to()+1).strip();}v=width(v,r.length(),r.fill(),r.direction().equals("l"));} + else v=" ".repeat(Objects.requireNonNullElse(r.length(),0));values.add(v); + } + var line=new StringBuilder(tags(s.lineStart(),row,fields,rowIndex)); + for(int n=0;nMAX_OUTPUT)throw bad("Die Exportdatei überschreitet 10 MB. Bitte den Zeitraum eingrenzen."); + } + if(!s.fileEnd().isEmpty())out.append(s.fileEnd()).append('\n');if(out.length()>MAX_OUTPUT)throw bad("Die Exportdatei ist zu groß.");if(out.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length>MAX_OUTPUT)throw bad("Die Exportdatei überschreitet 10 MB. Bitte den Zeitraum eingrenzen.");return out.toString(); + } + // PHP rewrites fixed RETRANS blocks on modified debtors. Do not corrupt unrelated formats when a marker is absent. + static String special(String row,String modify){if(modify==null||modify.isEmpty()||!row.startsWith(modify))return row;int at=row.indexOf("|9F|J|");if(at>=3&&at-3+20<=row.length())row=row.substring(0,at-3)+"|/|/|/|/|"+row.substring(at-3+20);at=row.indexOf("|003|Z");if(at>=0&&at+9+99<=row.length())row=row.substring(0,at+9)+"|/|/|/|/|/|/|/|/|/|/|/|/|/|"+row.substring(at+9+99);return row;} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java b/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java new file mode 100644 index 0000000..7f3221a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportRuns.java @@ -0,0 +1,66 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Durable file installation precedes the atomic change of master-data export flags. */ +@RestController +@RequestMapping("/api/data-transfer/master-export-runs") +class MasterExportRuns { + record Command(String requestId,MasterExports.Run run,boolean markExported,boolean confirmed){} + record Operation(String requestId,long actor,long headquarters,Command command,String digest,int bytes,int rows,long file,String status,LocalDateTime at,String logIds,ExportEncryption.Policy encryption){ + String name(){return encryption==null?command.run().filename():encryption.filename(command.run().filename());} + String physical(){return "ng-"+requestId+(encryption==null?"":encryption.suffix());} + } + final DatabaseService db;final BusinessSupport support;final MasterExports exports;final ExportFileStore files; + MasterExportRuns(DatabaseService db,BusinessSupport support,MasterExports exports,ExportFileStore files){this.db=db;this.support=support;this.exports=exports;this.files=files;} + @GetMapping Object operations(Authentication a){return operations(ApiController.user(a));} + @PostMapping Object start(Authentication a,@RequestBody Command c){return start(ApiController.user(a),c);} + @PostMapping("/{requestId}/resume") Object resume(Authentication a,@PathVariable String requestId){return resume(ApiController.user(a),requestId);} + @PostMapping("/{requestId}/cancel") Object cancel(Authentication a,@PathVariable String requestId){return cancel(ApiController.user(a),requestId);} + 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());});} + 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){ + if(c==null||c.run()==null||!c.confirmed())throw bad("Bitte den Export und seine Änderungen bestätigen.");token(c.requestId()); + var prior=db.readTransaction(()->find(u,c.requestId()));if(prior!=null){if(!prior.command().equals(c))throw bad("Vorgangskennung bereits für einen anderen Export verwendet.");return resume(u,c.requestId());} + var snapshot=db.readTransaction(()->exports.snapshot(u,c.run()));if(!snapshot.revision().equals(c.run().dataRevision()))throw bad("Die Exportvorschau ist nicht mehr aktuell.");if(snapshot.selection().rows().isEmpty())throw bad("Keine Datensätze für diesen Export.");var policy=db.readTransaction(()->{writable(u);return files.encryption.policy(db,u);});byte[] bytes=files.encryption.encrypt(MasterExportFormat.bytes(snapshot.content()),policy); + transaction(c.run().accounting(),()->{writable(u);var existing=find(u,c.requestId());if(existing!=null){if(!existing.command().equals(c))throw bad("Vorgangskennung bereits anders verwendet.");return null;}files.encryption.unchanged(db,u,policy);freeName(u,policy.filename(c.run().filename()),c.requestId());if(!exports.snapshot(u,c.run()).revision().equals(snapshot.revision()))throw bad("Die Exportdaten wurden geändert. Bitte Vorschau neu laden."); + var op=new Operation(c.requestId(),u.userId(),u.headquartersId(),c,DocumentStore.digest(bytes),bytes.length,snapshot.selection().rows().size(),0,"PENDING",null,"",policy);String encoded=support.encode(op);if(encoded.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Das Exportprofil ist zu umfangreich für einen gespeicherten Vorgang.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngmexport","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",c.requestId(),"gdc_content",encoded,"gdc_context","PENDING"));String payload=Base64.getEncoder().encodeToString(bytes);for(int offset=0,part=0;offset13_333_336)throw bad("Export-Zwischenspeicherung zu groß.");}byte[] bytes;try{bytes=Base64.getDecoder().decode(result.toString());}catch(IllegalArgumentException e){throw bad("Unvollständige Exportdatei.");}if(bytes.length!=op.bytes()||!DocumentStore.digest(bytes).equals(op.digest()))throw bad("Die gespeicherte Exportdatei ist unvollständig.");return bytes;} + void store(Operation op){byte[] bytes=payload(op);String target=op.physical(),stage=".ng-staging/"+op.requestId()+".part";try(var fs=files.store.open()){if(fs.exists(target)){if(!DocumentStore.digest(fs.read(target,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Exportdatei wurde verändert.");}else{fs.mkdir(".ng-staging");if(fs.exists(stage)&&!DocumentStore.digest(fs.read(stage,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))fs.remove(stage);if(!fs.exists(stage))fs.create(stage,bytes);fs.move(stage,target);}if(fs.exists(stage))fs.remove(stage);}} + Map resume(LegacyUser u,String requestId){var prior=db.readTransaction(()->find(u,requestId));if(prior==null)throw bad("Exportvorgang nicht gefunden.");var result=transaction(prior.command().run().accounting(),()->{ + var op=find(u,requestId);if(op==null)throw bad("Exportvorgang nicht gefunden.");if(op.status().equals("DONE"))return fields("completed",true,"file",op.file(),"replayed",true);if(!op.status().equals("PENDING"))throw bad("Der Exportvorgang wurde verworfen.");writable(u);files.encryption.unchanged(db,u,op.encryption());freeName(u,op.name(),op.requestId());var snapshot=exports.snapshot(u,op.command().run());if(!snapshot.revision().equals(op.command().run().dataRevision()))throw bad("Die Quelldaten oder Exportvorgaben wurden inzwischen geändert. Bitte diesen offenen Export verwerfen und eine neue Vorschau erstellen."); + boolean mark=op.command().markExported()&&(op.command().run().accounting()||op.command().run().status()!=0);var targets=new TreeSet();var deletes=new TreeSet();int category=op.command().run().category(); + if(mark&&category>=4)for(var row:snapshot.selection().raw())targets.add(id(row,"object_id")); + if(mark&&category<4)for(var row:snapshot.selection().raw()){long key=id(row,category==3?"crvh_id":"cmp_id");if(category==3&&id(row,"crvh_modify_status")==3)deletes.add(key);else targets.add(key);if(category!=3&&(!db.queryForList("SELECT cs.cs_id FROM customer cs WHERE cs.cmp_id=? AND cs.hq_id<>? LIMIT 1",key,u.headquartersId()).isEmpty()||!db.queryForList("SELECT cr.cr_id FROM courier cr WHERE cr.cmp_id=? AND cr.hq_id<>? LIMIT 1",key,u.headquartersId()).isEmpty()))throw bad("Eine exportierte Firma wird von einer anderen Niederlassung geteilt. Ihr Änderungskennzeichen darf hier nicht zurückgesetzt werden.");} + store(op);LocalDateTime at=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0); + if(mark&&category>=4)new AccountingExportArchive(db,support).capture(u,requestId,targets,at); + for(long key:targets)db.update(category>=4?"UPDATE job SET jb_export_time=? WHERE jb_id=?":category==3?"UPDATE couriervehicle SET crvh_modify_status=0,crvh_export_time=? WHERE crvh_id=?":"UPDATE company SET cmp_modify_status=0,cmp_export_time=? WHERE cmp_id=?",at,key); + if(mark&&category>=4)for(long key:targets)support.audit(u,key,0,0,164,"ACCOUNTING_EXPORT="+op.requestId(),op.requestId()); + for(long key:deletes)db.update("DELETE FROM couriervehicle WHERE crvh_id=? AND crvh_modify_status=3",key); + long file=db.insert("exportfiles",fields("hq_id",u.headquartersId(),"cs_id",0,"expf_name",op.name(),"expf_cryptname",op.physical(),"expf_storetime",at,"expf_ftpupload",0));String logIds=mark?String.join(",",targets.stream().map(String::valueOf).toList()):"";if(logIds.length()>65000)throw bad("Zu viele Kennungen für das bestehende Exportprotokoll."); + var done=new Operation(op.requestId(),op.actor(),op.headquarters(),op.command(),op.digest(),op.bytes(),op.rows(),file,"DONE",at,logIds,op.encryption());update(done);clearParts(op);support.audit(u,0,0,0,203,"MASTER_EXPORT="+file+"|CATEGORY="+category+"|ROWS="+op.rows()+"|MARKED="+targets.size()+"|REMOVED="+deletes.size()+"|SHA256="+op.digest(),op.requestId()); + return fields("completed",true,"file",file,"marked",targets.size(),"removed",deletes.size()); + });publishLog(u,requestId);new AccountingExportArchive(db,support).publish();return result;} + void update(Operation op){db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_obj_type='ngmexport' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(op),op.status().equals("DONE")&&!op.logIds().isEmpty()?"LOG_PENDING":op.status(),op.headquarters(),op.requestId());} + void clearParts(Operation op){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='ngmexpprt' AND gdc_obj_id=? AND gdc_gen_fieldname LIKE ? ESCAPE '!'",op.headquarters(),op.requestId()+"!_%");} + // MyISAM audit rows are appended after the transactional business commit. Exact content recovers a lost acknowledgement. + void publishLog(LegacyUser u,String requestId){publishLogs();} + @org.springframework.scheduling.annotation.Scheduled(initialDelay=60000,fixedDelay=30000) + public void publishLogs(){try{for(var candidate:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngmexport' AND gdc_context='LOG_PENDING' ORDER BY gdc_id LIMIT 30"))db.writeTransaction(()->{long key=id(candidate,"gdc_id");var rows=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngmexport' AND gdc_context='LOG_PENDING'",key);if(rows.isEmpty())return null;var op=decode(one(rows));if(!op.status().equals("DONE")||!op.logIds().matches("[0-9]+(?:,[0-9]+)*")||op.at()==null)throw bad("Ungültiger Exportprotokollauftrag.");var ids=db.queryForList("SELECT exl_id FROM exportlog WHERE expc_id=? AND exl_rows=? AND exl_exporttime=? AND usr_id=? LIMIT 1",op.command().run().category(),op.logIds(),op.at(),op.actor());if(ids.isEmpty())db.insert("exportlog",fields("expc_id",op.command().run().category(),"exl_rows",op.logIds(),"exl_exporttime",op.at(),"usr_id",op.actor()));db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",key);return null;});}catch(RuntimeException ignored){/* Durable LOG_PENDING entries are retried after an unavailable MyISAM log. */}} + Map cancel(LegacyUser u,String requestId){var prior=db.readTransaction(()->find(u,requestId));if(prior==null)throw bad("Exportvorgang nicht gefunden.");return transaction(prior.command().run().accounting(),()->{var op=find(u,requestId);if(op==null)throw bad("Exportvorgang nicht gefunden.");if(op.status().equals("CANCELLED"))return fields("cancelled",true);if(!op.status().equals("PENDING"))throw bad("Ein abgeschlossener Export kann nicht als offen verworfen werden.");String target=op.physical();if(!db.queryForList("SELECT expf_id FROM exportfiles WHERE expf_cryptname=?",target).isEmpty())throw bad("Die Datei wurde bereits veröffentlicht.");try(var fs=files.store.open()){if(fs.exists(target)){if(!DocumentStore.digest(fs.read(target,CustomerExportFormat.MAX_OUTPUT)).equals(op.digest()))throw bad("Die reservierte Datei wurde verändert.");fs.remove(target);}String stage=".ng-staging/"+op.requestId()+".part";if(fs.exists(stage))fs.remove(stage);}update(new Operation(op.requestId(),op.actor(),op.headquarters(),op.command(),op.digest(),op.bytes(),op.rows(),0,"CANCELLED",null,"",op.encryption()));clearParts(op);return fields("cancelled",true);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java b/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java new file mode 100644 index 0000000..5e93060 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExportTransfer.java @@ -0,0 +1,109 @@ +package de.stadtbote.votian; + +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; + +/** Explicit handover to the headquarters' existing accounting FTP directory. */ +@RestController +@RequestMapping("/api/data-transfer/master-export-files/transfer") +class MasterExportTransfer { + record Command(String requestId,long file,String revision){} + record Confirmation(boolean received){} + record Policy(boolean send,boolean list,List extensions,RemoteFileStore.Endpoint endpoint){ + String stamp(){var e=endpoint;return revision(List.of(send,list,extensions,e.protocol(),e.host(),e.port(),e.user(),e.password(),e.directory()));} + } + record Snapshot(long file,long headquarters,String name,String physical,String stored,int uploaded){} + record Content(Snapshot snapshot,Policy policy,byte[] bytes,String digest){ + String version(){return revision(List.of(snapshot,policy.stamp(),digest));} + } + record Operation(Command command,long actor,Snapshot snapshot,String endpoint,String digest,String state){ + Operation state(String next){return new Operation(command,actor,snapshot,endpoint,digest,next);} + } + static final Set STATES=Set.of("PREPARED","STAGING","READY","PUBLISHING","PUBLISHED","DONE","CANCELLED"); + final MasterExportFiles files;final RemoteFileStore remote;final DatabaseService db;final BusinessSupport support; + MasterExportTransfer(MasterExportFiles files,RemoteFileStore remote){this.files=files;this.remote=remote;db=files.exports.db;support=files.exports.support;} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @GetMapping("/remote") Object list(Authentication a){return list(ApiController.user(a));} + @GetMapping("/{file}") Object preview(Authentication a,@PathVariable long file){return preview(ApiController.user(a),file);} + @PostMapping Object start(Authentication a,@RequestBody Command c){return start(ApiController.user(a),c);} + @PostMapping("/{request}/resume") Object resume(Authentication a,@PathVariable String request){return resume(ApiController.user(a),request);} + @PostMapping("/{request}/cancel") Object cancel(Authentication a,@PathVariable String request){return cancel(ApiController.user(a),request);} + @PostMapping("/{request}/received") Object received(Authentication a,@PathVariable String request,@RequestBody Confirmation c){return received(ApiController.user(a),request,c);} + String parameter(long hq,String key){return new OrderEntryCatalog(db).parameter(hq,key);} + String fallback(long hq,String key){String v=parameter(hq,key);return v.isBlank()?parameter(0,key):v;} + Policy policy(LegacyUser u){ + files.exports.rights(u);long hq=u.headquartersId();String host=parameter(hq,"FTP_SERVER"),user=parameter(hq,"FTP_USER"),password=parameter(hq,"FTP_PASSWORD"),directory=parameter(hq,"FTP_UPLOADPATH"); + if(!host.isEmpty()&&!host.matches("[A-Za-z0-9.-]{1,253}"))throw bad("Ungültige Adresse des konfigurierten Exportservers."); + if(user.length()>255||password.length()>1000||user.indexOf('\u0000')>=0||password.indexOf('\u0000')>=0)throw bad("Ungültige Exportserver-Konfiguration."); + String raw=fallback(hq,"EXPORT_FTP_FILE_EXTENSIONS_ENABLED");var extensions=new LinkedHashSet(); + if(!raw.isBlank())for(String extension:raw.split("\\|",-1)){if(!extension.matches("\\.[A-Za-z0-9]{1,10}")||!extensions.add(extension)||extensions.size()>50)throw bad("Die freigegebenen Exportdateiendungen sind ungültig oder mehrdeutig.");} + boolean configured=!host.isBlank()&&!user.isBlank()&&!directory.isBlank(); + var endpoint=new RemoteFileStore.Endpoint("Export",RemoteFileStore.Protocol.FTP,host,21,user,password,RemoteFileStore.directory(directory)); + return new Policy(configured&&!fallback(hq,"EXPORT_MASK_FTP_UPLOAD_DISABLED").equals("1"),configured&&!fallback(hq,"EXPORT_MASK_FTP_SERVER_FILES_DISABLED").equals("1"),List.copyOf(extensions),endpoint); + } + static boolean extension(Policy p,String name){int dot=name.lastIndexOf('.');return dot>=0&&p.extensions().contains(name.substring(dot));} + Snapshot snapshot(LegacyUser u,long id){var f=files.files.file(u,id);var row=one(db.queryForList("SELECT expf_storetime,expf_ftpupload FROM exportfiles WHERE expf_id=? AND hq_id=? AND cs_id=0",id,u.headquartersId()));return new Snapshot(id,u.headquartersId(),f.name(),f.physical(),text(row,"expf_storetime"),(int)id(row,"expf_ftpupload"));} + Content content(LegacyUser u,long file){ + var p=policy(u);if(!p.send())throw bad("Die FTP-Übergabe ist für diese Niederlassung nicht freigegeben oder nicht vollständig eingerichtet.");var s=snapshot(u,file); + if(!extension(p,s.name()))throw bad("Diese Dateiendung ist nicht für die FTP-Übergabe freigegeben."); + byte[] bytes;try(var fs=files.files.store.open()){bytes=fs.read(s.physical(),CustomerExportFiles.MAX_BYTES);} + if(!snapshot(u,file).equals(s)||!policy(u).stamp().equals(p.stamp()))throw MasterDocuments.conflict(); + return new Content(s,p,bytes,DocumentStore.digest(bytes)); + } + Operation decode(Map row){try{var op=support.json.readValue(text(row,"gdc_content"),Operation.class);BusinessSupport.token(op.command().requestId());CustomerExportFiles.physical(op.snapshot().physical());DocumentStore.component(op.snapshot().name());if(!STATES.contains(op.state())||!op.digest().matches("[0-9a-f]{64}")||!op.endpoint().matches("[0-9a-f]{64}"))throw new IllegalArgumentException();return op;}catch(Exception e){throw bad("Der gespeicherte Exportübertragungsvorgang ist unvollständig.");}} + Operation find(String request){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexpftp' AND gdc_gen_fieldname=?",BusinessSupport.token(request));return rows.isEmpty()?null:decode(one(rows));} + 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 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(()->{ + if(command==null||command.file()<=0)throw bad("Bitte eine Exportdatei wählen.");BusinessSupport.token(command.requestId());var old=find(command.requestId());if(old!=null){actor(u,old);if(!old.command().equals(command))throw bad("Die Vorgangskennung gehört zu einer anderen Exportübertragung.");return resumeLocked(u,old);} + var c=content(u,command.file());if(c.snapshot().uploaded()!=0)throw bad("Diese Exportdatei ist bereits als übertragen gekennzeichnet.");if(!c.version().equals(command.revision()))throw MasterDocuments.conflict();support.transactional("exportfiles","genericdatacontainer"); + var op=new Operation(command,u.userId(),c.snapshot(),c.policy().stamp(),c.digest(),"PREPARED"); + db.customerExportFileWriteTransaction(()->{if(!snapshot(u,command.file()).equals(c.snapshot())||!policy(u).stamp().equals(c.policy().stamp()))throw MasterDocuments.conflict();var other=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngexpftp' AND gdc_obj_id=? AND gdc_context NOT IN ('DONE','CANCELLED') LIMIT 501",u.headquartersId());if(other.size()>500)throw bad("Zu viele offene Exportübertragungen.");if(other.stream().map(this::decode).anyMatch(o->o.snapshot().file()==command.file()))throw bad("Für diese Exportdatei gibt es bereits einen offenen Übertragungsvorgang.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngexpftp","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",command.requestId(),"gdc_content",support.encode(op),"gdc_context",op.state()));return null;});return resumeLocked(u,op); + });} + Map resume(LegacyUser u,String request){files.exports.rights(u);return db.documentOperation(()->{var op=find(request);if(op==null)throw bad("Exportübertragung nicht gefunden.");return resumeLocked(u,op);});} + static String stage(Operation op){return ".ng-transfer-"+op.command().requestId()+".part";} + void matching(RemoteFileStore.Connection fs,String name,Operation op){if(!DocumentStore.digest(fs.read(name,CustomerExportFiles.MAX_BYTES)).equals(op.digest()))throw bad("Die externe Datei passt nicht zum gespeicherten Übertragungsvorgang.");} + void current(LegacyUser u,Operation op){var s=snapshot(u,op.snapshot().file());var expected=op.snapshot();if(s.file()!=expected.file()||s.headquarters()!=expected.headquarters()||!s.name().equals(expected.name())||!s.physical().equals(expected.physical())||!s.stored().equals(expected.stored())||s.uploaded()<0||s.uploaded()>1)throw MasterDocuments.conflict();} + Map resumeLocked(LegacyUser u,Operation original){ + actor(u,original);if(Set.of("DONE","CANCELLED").contains(original.state()))return result(original);if(original.state().equals("PUBLISHED"))return finish(u,original,false); + var c=content(u,original.snapshot().file());if(!c.snapshot().equals(original.snapshot())||!c.policy().stamp().equals(original.endpoint())||!c.digest().equals(original.digest()))throw MasterDocuments.conflict();var op=original; + try(var fs=remote.open(c.policy().endpoint())){ + String name=op.snapshot().name(),stage=stage(op); + if(op.state().equals("PUBLISHING")){ + var target=fs.find(name);var staged=fs.find(stage); + if(target!=null&&staged==null){matching(fs,name,op);op=op.state("PUBLISHED");save(op);} + else if(target==null&&staged!=null){matching(fs,stage,op);if(!snapshot(u,op.snapshot().file()).equals(op.snapshot())||!policy(u).stamp().equals(op.endpoint()))throw MasterDocuments.conflict();fs.move(stage,name);op=op.state("PUBLISHED");save(op);} + else if(target==null){return fields("requestId",op.command().requestId(),"file",op.snapshot().file(),"name",name,"state",op.state(),"completed",false,"needsReceiptCheck",true,"message","Die Datei wurde möglicherweise bereits vom Empfänger übernommen. Es wird nichts erneut hochgeladen. Bitte den Eingang beim Empfänger prüfen.");} + else throw bad("Zieldatei und vorbereitete Datei sind gleichzeitig vorhanden. Bitte den Bestand auf dem Exportserver prüfen."); + }else{ + if(fs.find(name)!=null)throw bad("Der Dateiname ist auf dem Exportserver bereits vorhanden. Er wird nicht überschrieben."); + if(!op.state().equals("READY")){ + op=op.state("STAGING");save(op); + var staged=fs.find(stage);if(staged!=null){try{matching(fs,stage,op);}catch(org.springframework.web.server.ResponseStatusException e){if(e.getStatusCode().value()!=400)throw e;fs.remove(stage);}} + if(fs.find(stage)==null)fs.create(stage,c.bytes());matching(fs,stage,op);op=op.state("READY");save(op); + }else matching(fs,stage,op); + if(!snapshot(u,op.snapshot().file()).equals(op.snapshot())||!policy(u).stamp().equals(op.endpoint()))throw MasterDocuments.conflict(); + op=op.state("PUBLISHING");save(op);fs.move(stage,name);op=op.state("PUBLISHED");save(op); + } + } + return finish(u,op,false); + } + Map finish(LegacyUser u,Operation op,boolean manual){return db.customerExportFileWriteTransaction(()->{actor(u,op);current(u,op);db.update("UPDATE exportfiles SET expf_ftpupload=1 WHERE expf_id=? AND hq_id=? AND cs_id=0",op.snapshot().file(),u.headquartersId());var done=op.state("DONE");db.update("UPDATE genericdatacontainer SET gdc_context='DONE',gdc_content=? WHERE gdc_obj_type='ngexpftp' AND gdc_gen_fieldname=?",support.encode(done),op.command().requestId());support.audit(u,0,0,0,203,"EXPORT_FTP="+op.snapshot().file()+"|RECEIPT="+(manual?"MANUAL":"SERVER"),op.command().requestId());return result(done);});} + Map cancel(LegacyUser u,String request){files.exports.rights(u);return db.documentOperation(()->{ + var op=find(request);if(op==null)throw bad("Exportübertragung nicht gefunden.");actor(u,op);if(op.state().equals("CANCELLED"))return result(op); + if(!Set.of("PREPARED","STAGING","READY").contains(op.state()))throw bad("Die Übergabe wurde bereits begonnen. Bitte zuerst das Ergebnis prüfen."); + boolean remains=false; + if(!op.state().equals("PREPARED"))try{var p=policy(u);if(!p.stamp().equals(op.endpoint()))remains=true;else try(var fs=remote.open(p.endpoint())){if(fs.find(stage(op))!=null)fs.remove(stage(op));}}catch(org.springframework.web.server.ResponseStatusException e){remains=true;} + var cancelled=op.state("CANCELLED");save(cancelled);var result=result(cancelled);if(remains)result.put("message","Der Vorgang wurde verworfen. Eine temporäre Datei kann auf dem Exportserver verblieben sein; sie wurde nicht an den Empfänger freigegeben.");return result; + });} + Map received(LegacyUser u,String request,Confirmation confirmation){files.exports.rights(u);return db.documentOperation(()->{var op=find(request);if(op==null)throw bad("Exportübertragung nicht gefunden.");actor(u,op);if(op.state().equals("DONE"))return result(op);if(!op.state().equals("PUBLISHING")||confirmation==null||!confirmation.received())throw bad("Bitte den beim Empfänger geprüften Eingang ausdrücklich bestätigen.");return finish(u,op,true);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterExports.java b/backend/src/main/java/de/stadtbote/votian/MasterExports.java new file mode 100644 index 0000000..e00f0cd --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterExports.java @@ -0,0 +1,82 @@ +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.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.CustomerExportFormat.*; + +/** Central master-data export profiles and current PHP-compatible projections. */ +@RestController +@RequestMapping("/api/data-transfer/master-exports") +class MasterExports { + record Edit(String requestId,int category,long profile,String revision,String name,String extension,Settings settings) {} + record Run(int category,long profile,String revision,Settings settings,LocalDate from,LocalDate to,String delimiter,boolean headline,String filename,int status,String dataRevision,AccountingExportData.Filter filter) { + Run(int category,long profile,String revision,Settings settings,LocalDate from,LocalDate to,String delimiter,boolean headline,String filename,int status,String dataRevision){this(category,profile,revision,settings,from,to,delimiter,headline,filename,status,dataRevision,null);} + boolean accounting(){return category>=4&&category<=6;} + } + record State(long headquarters,List fields,List> profiles,Map profile) {} + final DatabaseService db; + final BusinessSupport support; + final MasterExportData data; + MasterExports(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;this.data=new MasterExportData(db);} + @GetMapping Object options(Authentication a){return options(ApiController.user(a));} + @GetMapping("/{category}/{profile}") Object view(Authentication a,@PathVariable int category,@PathVariable long profile){return view(ApiController.user(a),category,profile);} + @PostMapping("/preview") Object preview(Authentication a,@RequestBody Run r){return preview(ApiController.user(a),r,false);} + @PostMapping("/download") Object download(Authentication a,@RequestBody Run r){return preview(ApiController.user(a),r,true);} + @PostMapping("/save") Object save(Authentication a,@RequestBody Edit e){return save(ApiController.user(a),e);} + + void rights(LegacyUser u){u.require(6);var account=one(db.queryForList("SELECT e.emp_rights,u.usr_type FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE e.emp_id=? AND u.usr_id=?",u.employeeId(),u.userId()));if(id(account,"usr_type")!=1||!EmployeeAdministration.bit(text(account,"emp_rights"),6))throw MasterDirectoryScope.denied();} + Map options(LegacyUser u){return db.readTransaction(()->{rights(u);var categories=db.queryForList("SELECT expc_id AS id,expc_name AS label FROM exportcategory WHERE expc_usrtype=1 AND expc_type=0 AND expc_id IN (1,2,3,4,5,6) ORDER BY expc_id");return fields("categories",categories,"to",LocalDate.now(ZoneId.of("Europe/Berlin")),"vehicles",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' AND mt_sort BETWEEN 1 AND 99 ORDER BY mt_sort"));});} + State state(LegacyUser u,int category,long profile){ + rights(u);if(category<1||category>6||profile<0)throw bad("Ungültige Exportkategorie oder Profilnummer."); + if(db.queryForList("SELECT expc_id FROM exportcategory WHERE expc_id=? AND expc_usrtype=1 AND expc_type=0",category).size()!=1)throw MasterDirectoryScope.denied(); + var definitions=db.queryForList("SELECT expcf_id,expcf_field,expcf_field_sql,expcf_title FROM exportcategoryfields WHERE expc_id=? ORDER BY expcf_id LIMIT 301",category); + if(definitions.isEmpty()||definitions.size()>300)throw bad("Die Exportfelddefinition ist leer oder zu groß."); + var f=new ArrayList();var ids=new HashSet(); + for(var d:definitions){String expression=text(d,"expcf_field"),expected=(category>=4?AccountingExportFields.SQL:MasterExportFields.SQL.get(category)).get(expression); + if(expected==null||!expected.equals(text(d,"expcf_field_sql"))||!ids.add(id(d,"expcf_id")))throw bad("Das Exportfeld „"+text(d,"expcf_title")+"“ benötigt einen aktualisierten Feldabgleich."); + String key=MasterExportFields.key(expression);f.add(new Field(id(d,"expcf_id"),key,text(d,"expcf_title"),category>=4?AccountingExportFields.type(key):MasterExportFields.type(key))); + } + var profiles=db.queryForList("SELECT expp_id AS id,expp_name AS label FROM exportparameters WHERE hq_id=? AND cs_id=0 AND expc_id=? ORDER BY expp_name,expp_id LIMIT 1001",u.headquartersId(),category); + if(profiles.size()>1000)throw bad("Mehr als 1.000 Exportprofile in dieser Kategorie."); + var current=profile==0?Map.of():one(db.queryForList("SELECT * FROM exportparameters WHERE expp_id=? AND hq_id=? AND cs_id=0 AND expc_id=?",profile,u.headquartersId(),category)); + return new State(u.headquartersId(),List.copyOf(f),profiles,current); + } + static Object version(State s){return List.of(s.headquarters(),s.fields(),s.profiles(),s.profile());} + static Settings settings(State s){ + if(s.profile().isEmpty()){var rules=new ArrayList();for(var f:s.fields())rules.add(new Rule(f.id(),null,"l",rules.size(),"",false," ",null,"",null,null,false));return new Settings(rules,"","","","","");} + var p=s.profile();return new Settings(decode(text(p,"expp_parstr")),text(p,"expp_bolchars"),text(p,"expp_eolchars"),text(p,"expp_bofchars"),text(p,"expp_eofchars"),text(p,"expp_headline")); + } + Map view(LegacyUser u,int category,long profile){return db.readTransaction(()->{var s=state(u,category,profile);var settings=settings(s);String warning="";try{validate(settings,s.fields());}catch(org.springframework.web.server.ResponseStatusException ex){warning=ex.getReason();} + return fields("category",category,"profile",profile,"revision",revision(version(s)),"name",text(s.profile(),"expp_name"),"extension",text(s.profile(),"expp_fileextension"),"fields",s.fields(),"profiles",s.profiles(),"settings",settings,"warning",warning,"includesGroupCustomers",false); + });} + static String filename(String value){if(value==null||!value.matches("[\\p{L}\\p{N}_][\\p{L}\\p{N}_. -]{0,99}")||value.endsWith(".")||value.endsWith(" "))throw bad("Bitte einen Dateinamen ohne Pfadzeichen (höchstens 100 Zeichen) angeben.");return value;} + record Snapshot(State state,MasterExportData.Selection selection,String content,String revision){} + Snapshot snapshot(LegacyUser u,Run r){ + if(r==null)throw bad("Exportauswahl fehlt.");var s=state(u,r.category(),r.profile());unchanged(version(s),r.revision());validate(r.settings(),s.fields());filename(r.filename()); + var selected=r.accounting()?new AccountingExportData(db).select(u,new AccountingExportData.Query(r.category(),r.status(),r.from(),r.to(),r.filter())):data.select(u,r.category(),r.status());String content=MasterExportFormat.render(r.settings(),s.fields(),selected.rows(),r.delimiter(),r.headline(),r.category()==1?data.parameters(u.headquartersId()).get("ROW_EDIT_STATUS_MODIFY"):null); + if(r.accounting())content=content.replace("|"+" ".repeat(70),"|"); + return new Snapshot(s,selected,content,revision(Arrays.asList(version(s),r.category(),r.profile(),r.settings(),r.status(),r.delimiter(),r.headline(),r.filename(),r.from(),r.to(),r.filter(),selected))); + } + Map preview(LegacyUser u,Run r,boolean download){var s=db.readTransaction(()->snapshot(u,r));if(download&&!Objects.equals(r.dataRevision(),s.revision()))throw bad("Die Exportdaten haben sich geändert. Bitte die Vorschau neu laden.");if(!db.readTransaction(()->snapshot(u,r)).revision().equals(s.revision()))throw bad("Die Exportdaten wurden während der Vorschau geändert. Bitte erneut laden.");String content=s.content();return fields("filename",r.filename(),"rows",s.selection().rows().size(),"bytes",MasterExportFormat.bytes(content).length,"encoding","Windows-1252","base64",download?Base64.getEncoder().encodeToString(MasterExportFormat.bytes(content)):null,"content",download?content:content.substring(0,Math.min(20000,content.length())),"truncated",!download&&content.length()>20000,"revision",s.revision(),"skipped",s.selection().skipped(),"removedVehicles",r.category()==3?s.selection().raw().stream().filter(v->id(v,"crvh_modify_status")==3).count():0);} + Map save(LegacyUser u,Edit e){ + if(e==null)throw bad("Exportprofil fehlt.");token(e.requestId());String name=required(ArticleAdministration.clean(e.name(),100,"Profilname"),100,"Profilname"); + String extension=Objects.toString(e.extension(),"");if(!extension.matches("[A-Za-z0-9]{0,10}"))throw bad("Die Dateiendung darf höchstens zehn Buchstaben oder Ziffern enthalten."); + var intent=List.of("MASTER_EXPORT_PROFILE",e);support.transactional("exportparameters","genericdatacontainer"); + return db.masterExportWriteTransaction(()->{ + var s=state(u,e.category(),e.profile());Long done=support.receipt(u,e.requestId(),intent);if(done!=null)return fields("completed",true,"profile",done); + unchanged(version(s),e.revision());validate(e.settings(),s.fields()); + if(!db.queryForList("SELECT expp_id FROM exportparameters WHERE hq_id=? AND cs_id=0 AND expc_id=? AND expp_name=? AND expp_id<>?",u.headquartersId(),e.category(),name,e.profile()).isEmpty())throw bad("Dieser Profilname ist bereits vorhanden."); + var setting=e.settings();String encoded=encode(setting.rules());if(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Das Exportprofil ist zu groß für die vorhandene Speicherung."); + var values=fields("expp_name",name,"expp_parstr",encoded,"expp_headline",setting.headline(),"expp_bolchars",setting.lineStart(),"expp_eolchars",setting.lineEnd(),"expp_bofchars",setting.fileStart(),"expp_eofchars",setting.fileEnd(),"expp_fileextension",extension); + long id=e.profile();if(id==0){values.put("hq_id",u.headquartersId());values.put("cs_id",0);values.put("expc_id",e.category());id=db.insert("exportparameters",values);}else{var args=new ArrayList<>(values.values());args.add(id);db.update("UPDATE exportparameters SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE expp_id=?",args.toArray());} + support.audit(u,0,0,0,203,"MASTER_EXPORT_PROFILE="+id+"|CATEGORY="+e.category(),e.requestId());support.receipt(u,e.requestId(),intent,id);return fields("completed",true,"profile",id); + }); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterForms.java b/backend/src/main/java/de/stadtbote/votian/MasterForms.java new file mode 100644 index 0000000..5f3796a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterForms.java @@ -0,0 +1,76 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.jsoup.Jsoup; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.math.*; +import java.net.URI; +import java.time.*; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.BusinessSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.CrmScope.marks; + +/** Existing PHP second master pages (2/3/4) and explicitly configured courier forms. */ +@RestController +@RequestMapping("/api/master/{kind}/{object}/forms") +class MasterForms { + final DatabaseService db;final BusinessSupport support;final FormAdministration forms;final NewsletterTemplate templates;final String legacy;final FormFieldSupport special; + MasterForms(DatabaseService db,BusinessSupport support,FormAdministration forms,NewsletterTemplate templates,@Value("${app.legacy-url:}")String legacy){this.special=new FormFieldSupport(db);this.db=db;this.support=support;this.forms=forms;this.templates=templates;this.legacy=legacy.replaceAll("/+$","");} + record Save(String requestId,String revision,String schemaRevision,Map values){} + record State(long scope,List> definitions,Map category,String template,String configured,List> links){} + static final Set EDITABLE=Set.of("varchar","text","number","float","float_2","integer","checkbox","date","date_begin","date_end","birthdate","triggerdate","triggerdate_single","datetime","triggerdatetime","time","phone_business","phone_private","email_business","email_private"); + static final Set DATES=Set.of("date","date_begin","date_end","birthdate","triggerdate","triggerdate_single"); + static final Map COMMON=Map.ofEntries(Map.entry("company_name","cmp_comp"),Map.entry("company_name2","cmp_comp2"),Map.entry("street","ad_street"),Map.entry("housenumber","cmp_hsno"),Map.entry("zipcode","ad_zipcode"),Map.entry("city","ad_city"),Map.entry("taxno","cmp_tax_idno"),Map.entry("staxno","cmp_stax_idno"),Map.entry("bank","cmp_bank"),Map.entry("banknumber","cmp_bankno"),Map.entry("bankaccount","cmp_bankacc"),Map.entry("iban","cmp_iban"),Map.entry("swift","cmp_swift"),Map.entry("postage","cmp_postage"),Map.entry("name","usr_name"),Map.entry("firstname","usr_firstname"),Map.entry("phone","usr_phone"),Map.entry("phone2","usr_phone2"),Map.entry("fax","usr_fax"),Map.entry("email","usr_email"),Map.entry("birthdate","usr_birthdate")); + static final Map CUSTOMER=Map.of("eid","cs_eid","discount","cs_discount","provision","cs_prov","markup_provision","cs_markup_prov"); + static final Map COURIER=Map.of("courier_eid","cr_eid","imei","cr_imei","phone_pda","cr_mobile_pda","outlay","cr_outlay"); + static final Set SYSTEM=Set.of("empty_field","current_date","current_user","current_headquarters_id","current_headquarters_name","current_headquarters_mnemonic","current_headquarters_company_name","current_headquarters_company_name2","current_headquarters_company_logo","current_headquarters_street","current_headquarters_housenumber","current_headquarters_zipcode","current_headquarters_city"); + @GetMapping Map options(Authentication a,@PathVariable String kind,@PathVariable long object){return options(ApiController.user(a),kind,object);} + @GetMapping("/{category}") Map view(Authentication a,@PathVariable String kind,@PathVariable long object,@PathVariable long category){return view(ApiController.user(a),kind,object,category);} + @PutMapping("/{category}") Map save(Authentication a,@PathVariable String kind,@PathVariable long object,@PathVariable long category,@RequestBody Save s){return save(ApiController.user(a),kind,object,category,s);} + @GetMapping("/{category}/history") Map history(Authentication a,@PathVariable String kind,@PathVariable long object,@PathVariable long category,@RequestParam(defaultValue="0")int page){return history(ApiController.user(a),kind,object,category,page);} + @GetMapping("/{category}/fields/{field}/options") Map choices(Authentication a,@PathVariable String kind,@PathVariable long object,@PathVariable long category,@PathVariable long field,@RequestParam(defaultValue="")String q,@RequestParam(defaultValue="")String selected){return choices(ApiController.user(a),kind,object,category,field,q,selected);} + Map choices(LegacyUser u,String kind,long object,long category,long field,String q,String selected){return db.readTransaction(()->{access(u,kind,object);var d=one(state(u,kind,object,category).definitions().stream().filter(f->id(f,"id")==field).toList());return special.options(u,u.headquartersId(),text(d,"type"),q,selected);});} + static String type(String kind){return switch(kind){case "customers"->"cs";case "couriers"->"cr";case "vehicles"->"crvh";default->throw bad("Unbekannter Stammdatenbereich.");};} + static long base(String kind){return switch(kind){case "customers"->2;case "couriers"->3;case "vehicles"->4;default->throw bad("Unbekannter Stammdatenbereich.");};} + Map access(LegacyUser u,String kind,long object){type(kind);u.require(kind.equals("customers")?0:1);if(object<=0)throw bad("Bitte den Stammdatensatz zuerst anlegen.");String sql=switch(kind){ + case "customers"->"SELECT cs_id,hq_id,cmp_id,cs_admin,cs_eid,cs_discount,cs_prov,cs_markup_prov FROM customer WHERE cs_id=? AND hq_id=?"; + case "couriers"->"SELECT cr_id,hq_id,cmp_id,usr_id,cr_eid,cr_imei,cr_mobile_pda,cr_outlay FROM courier WHERE cr_id=? AND hq_id=?"; + default->"SELECT v.crvh_id,v.cr_id,v.crvh_sid,v.crvh_vh_sign FROM couriervehicle v JOIN courier cr ON cr.cr_id=v.cr_id WHERE v.crvh_id=? AND cr.hq_id=?";};return one(db.queryForList(sql,object,u.headquartersId()));} + String parameter(long hq,String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=? AND emp_id=0 AND par_key=?",hq,key);if(rows.size()>1)throw bad("Mehrdeutige Formularvorgabe: "+key);return rows.isEmpty()?"":text(rows.getFirst(),"par_value");} + List categories(String kind,String configured){var ids=new LinkedHashSet();ids.add(base(kind));if(kind.equals("couriers")&&!configured.isBlank())for(String raw:configured.split(",",-1)){String value=raw.strip();if(!value.matches("[1-9][0-9]{0,9}"))throw bad("MASK_FORMS_CR enthält eine ungültige Formularnummer.");long id=Long.parseLong(value);if(id>Integer.MAX_VALUE)throw bad("Ungültige Formularnummer.");ids.add(id);}if(ids.size()>50)throw bad("Mehr als 50 Zusatzformulare konfiguriert.");return List.copyOf(ids);} + boolean matches(String kind,Map category){String mnemonic=text(category,"mtfc_mnemonic"),expected=type(kind);return mnemonic.equals(expected)||kind.equals("couriers")&&id(category,"mtfc_id")!=3&&mnemonic.startsWith("cr_");} + Map options(LegacyUser u,String kind,long object){access(u,kind,object);String configured=kind.equals("couriers")?parameter(0,"MASK_FORMS_CR"):"";var items=new ArrayList>();var warnings=new ArrayList();for(long id:categories(kind,configured)){var found=db.queryForList("SELECT mtfc_id,mtfc_mnemonic,mtfc_description,mtft_id FROM metafieldcategory WHERE mtfc_id=?",id);if(found.size()!=1||!matches(kind,found.getFirst())){warnings.add("Formular "+id+" fehlt oder gehört nicht zu diesem Stammdatentyp.");continue;}items.add(fields("id",id,"label",text(found.getFirst(),"mtfc_description")));}return fields("items",items,"warnings",warnings,"additionalConfigured",!configured.isBlank());} + State state(LegacyUser u,String kind,long object,long category){String configured=kind.equals("couriers")?parameter(0,"MASK_FORMS_CR"):"";if(!categories(kind,configured).contains(category))throw bad("Dieses Formular ist für diesen Stammdatentyp nicht freigegeben.");var row=one(db.queryForList("SELECT mtfc_id,mtfc_mnemonic,mtfc_description,mtft_id FROM metafieldcategory WHERE mtfc_id=?",category));if(!matches(kind,row))throw bad("Die Formularzuordnung gehört zu einem anderen Stammdatentyp.");long scope=forms.fieldHeadquarters(u,category);var defs=db.queryForList(MetafieldRecords.definitionsSql(category),scope);if(defs.size()>500)throw bad("Mehr als 500 Formularfelder.");if(defs.stream().map(d->id(d,"keyId")).distinct().count()!=defs.size())throw bad("Doppelte Felddefinitionen. Bitte die Formularzuordnung prüfen.");var ts=db.queryForList("SELECT mtft_content FROM metafieldtemplate WHERE mtft_id=?",id(row,"mtft_id"));return new State(scope,defs,row,ts.isEmpty()?"":text(one(ts),"mtft_content"),configured,links(u,kind,object,defs));} + List> links(LegacyUser u,String kind,long object,List> defs){var result=new ArrayList>();for(var d:defs){String t=text(d,"type");if(!t.matches("link_static_[0-9]{1,10}"))continue;String key="MTF_LINK_STATIC_"+t.substring(12),raw=parameter(u.headquartersId(),key);if(raw.isBlank())raw=parameter(0,key);String[] parts=raw.split(",",3);String href=parts[0].strip().replace("",Long.toString(object)).replace("",type(kind));String url=safeLink(legacy,href);result.add(fields("fieldId",id(d,"id"),"label",parts.length>1&&!parts[1].isBlank()?parts[1].strip():Jsoup.parse(text(d,"label")).text(),"url",url,"available",!url.isEmpty()));}return result;} + static String safeLink(String legacy,String href){if(href.isBlank())return "";try{URI base=URI.create(legacy+"/admin/");URI url=base.resolve(href);return Set.of("http","https").contains(Objects.toString(url.getScheme(),"").toLowerCase(Locale.ROOT))&&url.getHost()!=null&&url.getUserInfo()==null?url.toASCIIString():"";}catch(IllegalArgumentException e){return "";}} + List> values(long object,List> defs){if(defs.isEmpty())return List.of();var args=new ArrayList(List.of(object));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());} + static String dbKey(String kind,String field){String prefix="db:"+type(kind)+":";if(!field.startsWith(prefix)||kind.equals("vehicles"))return null;String name=field.substring(prefix.length());return COMMON.containsKey(name)?COMMON.get(name):(kind.equals("customers")?CUSTOMER:COURIER).get(name);} + static boolean computed(String kind,String t){return t.startsWith("sys:")&&SYSTEM.contains(t.substring(4))||dbKey(kind,t)!=null||t.matches("link_static_[0-9]{1,10}");} + static boolean snapshot(String kind,String t){return dbKey(kind,t)!=null||FormFieldSupport.systemSnapshot(t);} + static boolean editable(String kind,String t){return FormFieldSupport.editable(t)||snapshot(kind,t);} + Map automatic(LegacyUser u,String kind,Map owner,State s){ + return new FormAutomaticValues(db,special).values(u,kind,owner,s.definitions(),s.links()); + } + Map view(LegacyUser u,String kind,long object,long category){return db.readTransaction(()->{var owner=access(u,kind,object);var state=state(u,kind,object,category);var raw=values(object,state.definitions());var stored=MetafieldRecords.values(raw);var rules=FormTemplateRules.read(state.template(),category,state.definitions());var display=FormFieldSupport.defaults(stored,automatic(u,kind,owner,state));display=rules.apply(display);var inputs=new LinkedHashMap<>(stored);for(var d:state.definitions())if(snapshot(kind,text(d,"type"))||rules.target(id(d,"id")))inputs.put(id(d,"id"),display.getOrDefault(id(d,"id"),""));display=new LinkedHashMap<>(FormFieldSupport.generated(state.definitions(),display,u.userId(),false));var rendered=special.display(u,state.definitions(),display,u.headquartersId());var schema=state.definitions().stream().map(d->{var f=special.definition(u,u.headquartersId(),d);f.put("label",Jsoup.parse(text(d,"label")).text());f.put("snapshot",snapshot(kind,text(d,"type")));f.put("calculated",rules.target(id(d,"id")));f.put("editable",editable(kind,text(d,"type"))&&!rules.target(id(d,"id")));return (Map)f;}).toList(); + var printDefs=schema.stream().map(d->{var f=new LinkedHashMap<>(d);if(text(d,"type").equals("sys:current_headquarters_company_logo"))f.put("type","system_image");return (Map)f;}).toList();var layoutValues=new LinkedHashMap<>(rendered);var layoutDefs=printDefs.stream().map(d->{var f=new LinkedHashMap<>(d);if(Boolean.TRUE.equals(d.get("editable"))||Boolean.TRUE.equals(d.get("calculated"))){f.put("type","varchar");layoutValues.put(id(d,"id"),"NGFORMFIELD"+id(d,"id")+"END");}return (Map)f;}).toList(); + return fields("title",text(state.category(),"mtfc_description"),"calculations",rules.calculations(),"templateWarning",rules.warning(),"configured",!schema.isEmpty(),"fields",schema,"values",inputs,"hasUnsavedDefaults",state.definitions().stream().anyMatch(d->(snapshot(kind,text(d,"type"))||rules.target(id(d,"id")))&&!inputs.getOrDefault(id(d,"id"),"").equals(stored.getOrDefault(id(d,"id"),""))),"revision",revision(raw),"schemaRevision",revision(state),"html",render(rendered,printDefs,state.template()),"layout",render(layoutValues,layoutDefs,state.template()),"links",state.links(),"unsupported",schema.stream().filter(d->!FormFieldSupport.editable(text(d,"type"))&&!FormFieldSupport.generated(text(d,"type"))&&!computed(kind,text(d,"type"))).map(d->text(d,"label")+" ("+text(d,"type")+")").toList()); + });} + String render(Map values,List> definitions,String template){return templates.render(fields("data",fields("values",values),"definitions",definitions,"template",template));} + static String validate(Map d,String value){String type=text(d,"type");if(value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65535||value.indexOf('\0')>=0)throw bad(text(d,"label")+": Feld ist zu lang oder ungültig.");if(value.isEmpty())return "";if(DATES.contains(type))EvaluationService.parseDate(value);else if(type.equals("datetime")||type.equals("triggerdatetime")){String sep=type.equals("datetime")?" ":"";if(!value.matches("[0-9]{2}\\.[0-9]{2}\\.[0-9]{4}"+sep+"[0-9]{2}:[0-9]{2}"))throw bad("Datum/Uhrzeit ist ungültig.");EvaluationService.parseDate(value.substring(0,10));CustomerServiceSettings.time(value.substring(10+sep.length()));}else if(type.equals("time"))CustomerServiceSettings.time(value);else if(type.equals("checkbox")&&!value.equals("1"))throw bad("Ungültiger Checkbox-Wert.");else if(Set.of("number","float","float_2","integer").contains(type)){if(!value.matches("-?[0-9]{1,12}(?:[.,][0-9]{1,6})?"))throw bad("Ungültige Zahl; höchstens sechs Nachkommastellen.");var n=new BigDecimal(value.replace(',','.'));if(type.equals("float_2"))n=n.setScale(2,RoundingMode.HALF_UP);if(type.equals("integer"))n=n.setScale(0,RoundingMode.HALF_UP);return n.stripTrailingZeros().toPlainString();}return value;} + Map save(LegacyUser u,String kind,long object,long category,Save s){access(u,kind,object);if(s==null||s.values()==null||s.values().size()>500)throw bad("Formularwerte fehlen.");token(s.requestId());return db.masterFormWriteTransaction(special.sources(state(u,kind,object,category).definitions()),()->{ + var owner=access(u,kind,object);var state=state(u,kind,object,category);var receipt=List.of("master-form",kind,object,category,s);if(support.receipt(u,s.requestId(),receipt)!=null)return fields("saved",true); + unchanged(state,s.schemaRevision());var rules=FormTemplateRules.read(state.template(),category,state.definitions());rules.requireSupported();if(state.definitions().isEmpty())throw bad("Für dieses Formular sind keine Felder eingerichtet.");var keys=state.definitions().stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");var raw=values(object,state.definitions());unchanged(raw,s.revision());var before=MetafieldRecords.values(raw);var next=new LinkedHashMap<>(before); + for(var d:state.definitions()){long key=id(d,"id");if(rules.target(key))continue;String old=before.getOrDefault(key,""),value=Objects.requireNonNullElse(s.values().getOrDefault(key,old),"");if(old.equals(value))continue;if(!editable(kind,text(d,"type")))throw bad("Automatische oder nicht unterstützte Felder dürfen nicht überschrieben werden: "+Jsoup.parse(text(d,"label")).text());next.put(key,special.validate(u,u.headquartersId(),d,value,old));} + next=new LinkedHashMap<>(FormFieldSupport.generated(state.definitions(),rules.apply(next),u.userId(),true));int changed=0; + for(var d:state.definitions()){long key=id(d,"id");String value=next.getOrDefault(key,"");if(value.equals(before.getOrDefault(key,"")))continue;changed++; + db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id=?",object,key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,object,value);if(id(d,"history")==1)db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,object,value,LocalDateTime.now(ZoneId.of("Europe/Berlin"))); + } + if(changed>0)support.audit(u,0,kind.equals("customers")?object:0,kind.equals("couriers")?object:kind.equals("vehicles")?id(owner,"cr_id"):0,kind.equals("customers")?36:kind.equals("vehicles")?86:37,"Zusatzformular "+category+" für "+kind+" #"+object+" geändert: "+changed+" Felder.",s.requestId());support.receipt(u,s.requestId(),receipt,object);return fields("saved",true); + });} + Map history(LegacyUser u,String kind,long object,long category,int page){if(page<0||page>100000)throw bad("Ungültige Seite.");return db.readTransaction(()->{access(u,kind,object);var defs=state(u,kind,object,category).definitions();if(defs.isEmpty())return fields("items",List.of(),"more",false,"page",page);var args=new ArrayList(List.of(object));args.addAll(defs.stream().map(d->id(d,"id")).toList());var found=db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC,mtfck_id,mtfv_value LIMIT 101 OFFSET "+page*100,args.toArray());return fields("items",found.stream().limit(100).toList(),"more",found.size()>100,"page",page);});} +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterHistory.java b/backend/src/main/java/de/stadtbote/votian/MasterHistory.java new file mode 100644 index 0000000..3f00745 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterHistory.java @@ -0,0 +1,52 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.dao.DataAccessException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +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; + +/** Scoped customer/courier history. Descriptions are parsed, never returned as raw legacy HTML. */ +@RestController +class MasterHistory { + final DatabaseService db;final BusinessSupport support; + MasterHistory(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/master-lists/{kind}/{object}/history") Object get(Authentication a,@PathVariable MasterListColumns.Kind kind,@PathVariable long object,@RequestParam LocalDate from,@RequestParam LocalDate to,@RequestParam(defaultValue="log") String source,@RequestParam(defaultValue="0") int operation){return history(ApiController.user(a),kind,object,from,to,source,operation);} + static List operations(MasterListColumns.Kind kind){return kind==MasterListColumns.Kind.customers?List.of(0,36,48):List.of(0,37,86);} + static String scope(MasterListColumns.Kind kind){return kind==MasterListColumns.Kind.customers?"cs":"cr";} + Map owner(LegacyUser u,MasterListColumns.Kind kind,long object){u.require(kind.right());String p=scope(kind);return one(db.queryForList("SELECT cmp.cmp_comp AS name FROM "+(p.equals("cs")?"customer":"courier")+" o JOIN company cmp ON cmp.cmp_id=o.cmp_id WHERE o."+p+"_id=? AND o.hq_id=?",object,u.headquartersId()));} + Map history(LegacyUser u,MasterListColumns.Kind kind,long object,LocalDate from,LocalDate to,String source,int operation){ + u.require(kind.right());if(from==null||to==null||from.isAfter(to)||from.getYear()<1900||to.getYear()>9998||!EmployeeHistory.TABLES.contains(source)||!operations(kind).contains(operation))throw bad("Ungültiger Historienfilter."); + return db.readTransaction(()->{var owner=owner(u,kind,object);try{ + var available=db.queryForList("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN ('log','loghistory','loghistory2','loghistory3')",support.historySchema.replace("`","")).stream().map(r->text(r,"name")).filter(EmployeeHistory.TABLES::contains).sorted(Comparator.comparingInt(EmployeeHistory.TABLES::indexOf)).toList();if(!available.contains(source))throw bad("Dieser Historienbestand ist nicht verfügbar."); + String target=scope(kind)+"_id";var args=new ArrayList(List.of(object,from.atStartOfDay(),to.plusDays(1).atStartOfDay()));if(operation>0)args.add(operation); + var raw=db.queryForList("SELECT l.log_id,l.logo_id,l.log_createtime,l.usr_id,l.jb_id,l.logo_description,o.logo_op_shortname,u.usr_name,u.usr_firstname,u.usr_phone FROM "+support.historySchema+".`"+source+"` l LEFT JOIN "+support.historySchema+".logoperation o ON o.logo_id=l.logo_id LEFT JOIN user u ON u.usr_id=l.usr_id WHERE l."+target+"=? AND l.log_createtime>=? AND l.log_createtime0?" AND l.logo_id=?":"")+" ORDER BY l.log_createtime DESC,l.log_id DESC LIMIT 2501",args.toArray()); + var items=new ArrayList>();for(var r:raw)items.add(item(kind,r,source,false)); + if(source.equals("log")){ + String property=kind==MasterListColumns.Kind.customers?"customer":"courier"; + var queued=db.queryForList("SELECT g.gdc_id,g.gdc_content,u.usr_name,u.usr_firstname,u.usr_phone FROM genericdatacontainer g LEFT JOIN user u ON u.usr_id=JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.user')) WHERE g.gdc_obj_type='ngaudit' AND g.gdc_context='PENDING' AND JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$."+property+"'))=? AND JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.at'))>=? AND JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(g.gdc_content),g.gdc_content,'{}'),'$.at'))();raw.forEach(r->published.add(text(r,"logo_description"))); + for(var q:queued){BusinessSupport.Audit a;try{a=support.json.readValue(text(q,"gdc_content"),BusinessSupport.Audit.class);}catch(Exception ex){continue;}if((kind==MasterListColumns.Kind.customers?a.customer():a.courier())!=object||a.headquarters()!=u.headquartersId()||operation>0&&a.operation()!=operation||published.contains(a.description()))continue;var r=new LinkedHashMap<>(q);r.putAll(fields("log_id",id(q,"gdc_id"),"logo_id",a.operation(),"log_createtime",a.at(),"usr_id",a.user(),"jb_id",a.job(),"logo_description",a.description()));items.add(item(kind,r,"pending",true));} + } + items.sort(Comparator.,String>comparing(r->text(r,"at")).reversed().thenComparing(Comparator.comparingLong((Map r)->id(r,"id")).reversed())); + return fields("name",text(owner,"name"),"from",from,"to",to,"source",source,"sources",available,"items",items.stream().limit(2500).toList(),"truncated",items.size()>2500); + }catch(DataAccessException e){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Die Stammdatenhistorie ist derzeit nicht erreichbar.");}}); + } + Map item(MasterListColumns.Kind kind,Map r,String source,boolean pending){String actor=join(text(r,"usr_firstname"),text(r,"usr_name"));String action=text(r,"logo_op_shortname");if(action.isBlank())action=switch((int)id(r,"logo_id")){case 34,35->"Angelegt";case 36,37->"Stammdaten geändert";case 48,49->"Kostenstelle geändert";case 85,86->"Fahrzeug geändert";default->"Vorgang "+id(r,"logo_id");};return fields("id",id(r,"log_id"),"key",source+":"+id(r,"log_id"),"at",text(r,"log_createtime").replace(' ','T'),"action",action,"actor",actor.isBlank()?"Benutzer "+id(r,"usr_id"):actor,"phone",text(r,"usr_phone"),"job",id(r,"jb_id"),"detail",details(kind,(int)id(r,"logo_id"),text(r,"logo_description")),"pending",pending);} + static String details(MasterListColumns.Kind kind,int operation,String raw){ + var out=new ArrayList();String value=raw.replace("","").replace("","").replaceFirst(" \\| NG=[A-Za-z0-9-]+$",""); + // Only positions verified in current customer_special.php / courier_special.php. + if((kind==MasterListColumns.Kind.customers&&Set.of(34,36).contains(operation)||kind==MasterListColumns.Kind.couriers&&Set.of(35,37).contains(operation))&&(value.startsWith("STATUS_NEW=")||value.startsWith("STATUS_MODIFIED="))){ + String[] parts=value.substring(value.indexOf('=')+1).split(";",-1);Map labels=kind==MasterListColumns.Kind.customers?Map.ofEntries(Map.entry(0,"Freigabe"),Map.entry(1,"Firma"),Map.entry(2,"Firma 2"),Map.entry(3,"EID"),Map.entry(4,"Name"),Map.entry(5,"Vorname"),Map.entry(6,"Telefon"),Map.entry(9,"E-Mail"),Map.entry(31,"Straße"),Map.entry(32,"Hausnummer"),Map.entry(33,"PLZ"),Map.entry(34,"Ort")):Map.ofEntries(Map.entry(0,"Freigabe"),Map.entry(1,"Firma"),Map.entry(2,"Firma 2"),Map.entry(3,"Name"),Map.entry(4,"Vorname"),Map.entry(5,"Telefon"),Map.entry(8,"E-Mail"),Map.entry(9,"SID"),Map.entry(10,"EID"),Map.entry(21,"Straße"),Map.entry(22,"Hausnummer"),Map.entry(23,"PLZ"),Map.entry(24,"Ort")); + labels.keySet().stream().sorted().forEach(i->{if(i();for(var defs:List.of(MasterData.COMMON,MasterData.CUSTOMER,MasterData.COURIER,MasterData.VEHICLE,MasterData.CENTER))for(var f:defs)known.put(f.key(),f.label());var names=Arrays.stream(keys.split(",")).filter(known::containsKey).map(known::get).distinct().toList();if(!names.isEmpty())out.add("Gespeicherte Felder: "+String.join(", ",names));} + return out.isEmpty()?"Für diesen Vorgang sind noch keine aufbereiteten Detailangaben verfügbar.":String.join("\n",out); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java b/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java new file mode 100644 index 0000000..1c54bb8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MasterListColumns.java @@ -0,0 +1,53 @@ +package de.stadtbote.votian; + +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; + +/** Personal customer/courier list column order; legacy length parameters remain untouched. */ +@RestController +class MasterListColumns { + enum Kind { + customers("MASK_CS_LIST_COLS","cmp_comp,cmp_comp2,cmp_comp3,cmp_comp4,ad_street,ad_zipcode,ad_city,cmp_match,cs_eid,usr_phone,usr_email,csc_id_jb_edit,cs_id_history,cs_id_report,cmp_authenticated_img,cs_id_jb_permanent,cmp_id_edit,cmp_hsno,usr_name,usr_firstname,cs_num_of_documents,br_key,br_name,cs_discount,cs_eid_old,cs_become_cs_date,usr_inv_email,usr_reminder_email,cmp_sustainability_email,cmp_stax_idno,tx_sign,tx_value,cmp_inv2hq,mt_value_payment_term","Firma|Firma 2|Firma 3|Firma 4|Straße|PLZ|Ort|Freitext|EID|Telefon|E-Mail|Auftrag|Historie|Berichte|Kundenstatus|Daueraufträge|Bearbeiten|Hausnr.|Name|Vorname|Dokumente|Branchenschlüssel|Branche|Rabatt|Alt-EID|Kunde seit|Rechnungs-E-Mail|Mahn-E-Mail|Nachhaltigkeits-E-Mail|USt-IdNr.|USt-Kennzeichen|USt-Satz|Einzelrechnung|Zahlungsbedingung","cmp_comp,cmp_comp2,ad_street,ad_zipcode,cmp_match,cs_eid,usr_phone,usr_email,csc_id_jb_edit,cs_id_history,cs_id_report,cmp_authenticated_img,cs_id_jb_permanent,cmp_id_edit"), + couriers("MASK_CR_LIST_COLS","cr_sid,crvh_vh_sign,cr_eid,crvh_prov,usr_name,usr_firstname,usr_phone,cr_mobile_pda,cmp_authenticated_img,cr_id_history,cr_id_report,cmp_id_edit,ad_street,ad_zipcode,ad_city,crvh_remark,crvh_freetext_1,crvh_measurement,crvh_length,crvh_width,crvh_height,crvh_position,usr_account,cr_num_of_documents,crvh_num_of_documents,crvh_payload,crvh_env_badge_level,crvh_corporate_identity,crvh_licence_date,crvh_comm_no,cmp_comp,cr_imei,cmp_freetext1,crvh_drivetype,vht_id_inv,usr_email,crvh_totalweight,cr_serialno","SID|Kennzeichen|EID|Provision|Name|Vorname|Telefon|Mobil-PDA|Freigabe|Historie|Berichte|Bearbeiten|Straße|PLZ|Ort|Fahrzeugbemerkung|Fahrzeugfreitext|Fahrzeugmaße|Länge|Breite|Höhe|Stellplätze|Anmeldename|Transporteur-Dokumente|Fahrzeug-Dokumente|Nutzlast|Umweltstufe|CI|Erstzulassung|Kommissionsnummer|Firma|IMEI|Freitext|Antriebsart|Rechnungsfahrzeugtyp|E-Mail|Zulässiges Gesamtgewicht|Seriennummer","cr_sid,crvh_vh_sign,cr_eid,crvh_prov,usr_name,usr_firstname,usr_phone,cr_mobile_pda,cmp_authenticated_img,cr_id_history,cr_id_report,cmp_id_edit,ad_zipcode,cr_serialno"); + final String parameter;final List keys,labels,defaults; + Kind(String parameter,String keys,String labels,String defaults){this.parameter=parameter;this.keys=List.of(keys.split(","));this.labels=List.of(labels.split("\\|"));this.defaults=List.of(defaults.split(","));} + int right(){return this==customers?0:1;} + String label(String key){int i=keys.indexOf(key);return i<0?key:labels.get(i);} + } + record Command(String requestId,String revision,List columns){} + record Layout(List columns,String warning){} + record State(EmployeeListPreferences.State access,List> rows){} + final DatabaseService db;final BusinessSupport support; + MasterListColumns(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;} + @GetMapping("/api/account/master-list-columns/{kind}") Object own(Authentication a,@PathVariable Kind kind){var u=ApiController.user(a);return view(u,u.employeeId(),kind);} + @PutMapping("/api/account/master-list-columns/{kind}") Object ownSave(Authentication a,@PathVariable Kind kind,@RequestBody Command c){var u=ApiController.user(a);return save(u,u.employeeId(),kind,c);} + @GetMapping("/api/administration/employees/{employee}/master-list-columns/{kind}") Object get(Authentication a,@PathVariable Kind kind,@PathVariable long employee){return view(ApiController.user(a),employee,kind);} + @PutMapping("/api/administration/employees/{employee}/master-list-columns/{kind}") Object put(Authentication a,@PathVariable Kind kind,@PathVariable long employee,@RequestBody Command c){return save(ApiController.user(a),employee,kind,c);} + static List> rows(DatabaseService db,long employee,long hq,long home,long md,Kind kind){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_key,par_id",employee,hq,home,md,kind.parameter);} + State state(LegacyUser u,long employee,Kind kind){var a=new EmployeeListPreferences(db,support).state(u,employee);return new State(a,rows(db,employee,u.headquartersId(),id(a.person(),"hq_id"),a.mandator(),kind));} + 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,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(); + 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.":""); + } + static Layout runtime(DatabaseService db,LegacyUser u,Kind kind){long md=mandator(db,u);var rows=rows(db,u.employeeId(),u.headquartersId(),u.originalHeadquartersId(),md,kind);if(!writable(rows,md))throw bad("Die Listenspalten sind mehrfach vorhanden oder falsch zugeordnet.");return layout(rows,u.headquartersId(),kind);} + static String revisionOf(State s){return revision(List.of(s.access().person(),s.access().access().actor(),s.access().access().root(),s.access().mandator(),s.rows()));} + static List> definitions(Kind kind){return kind.keys.stream().map(k->(Map)fields("key",k,"label",kind.label(k))).toList();} + Map view(LegacyUser u,long employee,Kind kind){return db.readTransaction(()->{var s=state(u,employee,kind);var l=layout(s.rows(),u.headquartersId(),kind);boolean can=writable(s.rows(),s.access().mandator());return fields("employee",EmployeeAdministration.profile(s.access().person()),"headquartersId",u.headquartersId(),"columns",l.columns(),"definitions",definitions(kind),"defaults",kind.defaults,"max",kind.keys.size(),"revision",revisionOf(s),"canSave",u.headquartersId()==u.originalHeadquartersId()&&can,"warning",u.headquartersId()!=u.originalHeadquartersId()?"Persönliche Einstellungen können in der eigenen Niederlassung geändert werden.":can?l.warning():"Die Spaltenparameter sind mehrfach vorhanden oder falsch zugeordnet. Bitte den Bestand zuerst klären.");});} + static void validate(Kind kind,Command c){if(c==null||c.columns()==null)throw bad("Spalten fehlen.");BusinessSupport.token(c.requestId());if(c.columns().isEmpty()||c.columns().size()>kind.keys.size()||c.columns().stream().anyMatch(x->x==null||!kind.keys.contains(x)))throw bad("Bitte gültige Spalten auswählen.");} + Map save(LegacyUser u,long employee,Kind kind,Command c){if(u.headquartersId()!=u.originalHeadquartersId())throw EmployeeAdministration.denied();validate(kind,c);support.transactional("parameter","genericdatacontainer");return db.employeePreferencesWriteTransaction(()->{ + var s=state(u,employee,kind);var intent=List.of("EMPLOYEE_MASTER_COLUMNS",employee,kind,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(kind.parameter,String.join(",",c.columns())); + 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,"MASTER_COLUMNS_KIND="+kind+" | MASTER_COLUMNS_BEFORE="+support.encode(layout(s.rows(),u.headquartersId(),kind).columns())+" | MASTER_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/MetaObjectRegistration.java b/backend/src/main/java/de/stadtbote/votian/MetaObjectRegistration.java new file mode 100644 index 0000000..f44a2bd --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/MetaObjectRegistration.java @@ -0,0 +1,68 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +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; + +/** Durable creation/deletion outbox for PHP GLOBAL_USAGE_HASH. No bulk scan or backfill. */ +@Service +class MetaObjectRegistration { + record Pending(long headquarters,long instance,String type,long object,int attempts,String error) {} + record Claim(long row,String lease,Pending pending,boolean removal) {} + final DatabaseService db;final BusinessSupport support;final boolean enabled;final Clock clock; + @org.springframework.beans.factory.annotation.Autowired + MetaObjectRegistration(DatabaseService db,BusinessSupport support,@Value("${app.meta-object-sync.enabled:true}")boolean enabled){this(db,support,enabled,Clock.systemUTC());} + MetaObjectRegistration(DatabaseService db,BusinessSupport support,boolean enabled,Clock clock){this.db=db;this.support=support;this.enabled=enabled;this.clock=clock;} + static long instance(DatabaseService db){String raw=new OrderEntryCatalog(db).parameter(0,"GLOBAL_UNIQUE_DB_INSTANCE_NO");if(!raw.matches("[1-9][0-9]{0,9}")||Long.parseLong(raw)>Integer.MAX_VALUE)throw bad("Die globale Datenbankinstanz für persönliche Links fehlt oder ist ungültig.");return Long.parseLong(raw);} + static void enqueue(DatabaseService db,BusinessSupport support,long hq,String type,long object){enqueue(db,support,hq,type,object,false);} + static void enqueueRemoval(DatabaseService db,BusinessSupport support,long hq,long user){enqueue(db,support,hq,"usr",user,true);} + static void enqueue(DatabaseService db,BusinessSupport support,long hq,String type,long object,boolean removal){ + String queue=removal?"ngmetadel":"ngmeta"; + if(hq<=0||object<=0||!Set.of("cs","cr","usr").contains(type))throw new IllegalArgumentException("Ungültige Metaobjekt-Vormerkung."); + if(!db.lockRows("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_gen_fieldname=?",queue,object,type).isEmpty())throw bad("Die zentrale Objektvormerkung besteht bereits."); + long instance=0;try{instance=instance(db);}catch(RuntimeException ignored){/* A missing global configuration must not lose the local creation event. */} + db.insert("genericdatacontainer",fields("gdc_obj_type",queue,"gdc_obj_id",object,"gdc_gen_fieldname",type,"gdc_context","PENDING","gdc_content",support.encode(new Pending(hq,instance,type,object,0,"")))); + } + Pending decode(Map row){try{var p=support.json.readValue(text(row,"gdc_content"),Pending.class);if(p.headquarters()<=0||p.instance()<0||p.object()!=id(row,"gdc_obj_id")||!p.type().equals(text(row,"gdc_gen_fieldname"))||!Set.of("cs","cr","usr").contains(p.type())||p.attempts()<0)throw new IllegalArgumentException();return p;}catch(Exception e){throw bad("Die Vormerkung für persönliche Links ist ungültig.");}} + static String prefix(String state,long seconds){return state+":"+String.format(Locale.ROOT,"%012d",seconds)+":";} + boolean due(String context){return context.equals("PENDING")||context.startsWith("RETRY:")&&context.compareTo(prefix("RETRY",clock.instant().getEpochSecond()+1))<0||context.startsWith("SENDING:")&&context.compareTo(prefix("SENDING",clock.instant().getEpochSecond()+1))<0;} + boolean exists(Pending p){String table=switch(p.type()){case "cs"->"customer";case "cr"->"courier";case "usr"->"user";default->throw new IllegalArgumentException();};String key=p.type()+"_id";var rows=db.queryForList("SELECT hq_id FROM "+table+" WHERE "+key+"=?",p.object());return rows.size()==1&&id(rows.getFirst(),"hq_id")==p.headquarters();} + boolean userDeleted(long user){return db.queryForList("SELECT usr_id FROM user WHERE usr_id=?",user).isEmpty();} + void update(long row,Pending p,String context){db.update("UPDATE genericdatacontainer SET gdc_content=?,gdc_context=? WHERE gdc_id=?",support.encode(p),context,row);} + Claim claim(long row){support.transactional("genericdatacontainer");return db.writeTransaction(()->{ + var rows=db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type IN ('ngmeta','ngmetadel')",row);if(rows.isEmpty())return null;var r=one(rows);if(!due(text(r,"gdc_context")))return null;var p=decode(r);boolean removal=text(r,"gdc_obj_type").equals("ngmetadel"); + if(removal&&(!p.type().equals("usr")||!userDeleted(p.object())||p.instance()==0)){update(row,new Pending(p.headquarters(),p.instance(),p.type(),p.object(),p.attempts(),"Die Löschzuordnung ist nicht eindeutig oder das Benutzerkonto besteht noch. Bitte den zentralen Bestand prüfen."),"BLOCKED");return null;} + if(!removal&&!exists(p)){update(row,new Pending(p.headquarters(),p.instance(),p.type(),p.object(),p.attempts(),"Das zugehörige Objekt ist nicht mehr verfügbar."),"ORPHANED");return null;} + long current;try{current=instance(db);}catch(RuntimeException e){update(row,new Pending(p.headquarters(),p.instance(),p.type(),p.object(),p.attempts(),"Die globale Datenbankinstanz fehlt oder ist nicht eindeutig."),"BLOCKED");return null;} + if(p.instance()>0&¤t!=p.instance()){update(row,new Pending(p.headquarters(),p.instance(),p.type(),p.object(),p.attempts(),"Die globale Datenbankinstanz hat sich seit der Vormerkung geändert."),"BLOCKED");return null;} + p=new Pending(p.headquarters(),current,p.type(),p.object(),Math.min(100000,p.attempts()+1),"");String lease=prefix("SENDING",clock.instant().plusSeconds(60).getEpochSecond())+UUID.randomUUID();update(row,p,lease);return new Claim(row,lease,p,removal); + });} + String process(long row){if(!enabled)return "DISABLED";var claim=claim(row);if(claim==null)return "SKIPPED";String state="DONE",error=""; + try{var p=claim.pending();if(claim.removal())db.removeMetaUserHash(p.instance(),p.object(),()->userDeleted(p.object()));else db.ensureMetaObjectHash(p.instance(),p.type(),p.object(),()->exists(p));} + catch(IllegalStateException|IllegalArgumentException e){state="BLOCKED";error="Der zentrale Schlüsselbestand ist widersprüchlich oder ungültig. Bitte die Zuordnung prüfen.";} + catch(RuntimeException e){state="RETRY";error="Die zentrale Schlüsselablage ist derzeit nicht erreichbar. Der Abgleich wird erneut versucht.";} + finish(claim,state,error);return state; + } + void finish(Claim claim,String state,String error){support.transactional("genericdatacontainer");db.writeTransaction(()->{ + var rows=db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type IN ('ngmeta','ngmetadel')",claim.row());if(rows.size()!=1||!text(rows.getFirst(),"gdc_context").equals(claim.lease()))return null; + var p=claim.pending();String context=state.equals("RETRY")?prefix("RETRY",clock.instant().plusSeconds(Math.min(3600,60L*(1L<> status(long headquarters,String type,long object){return db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngmeta' AND gdc_obj_id=? AND gdc_gen_fieldname=? ORDER BY gdc_id",object,type).stream().>map(r->{var p=decode(r);if(p.headquarters()!=headquarters)throw bad("Die Objektvormerkung gehört zu einer anderen Niederlassung.");String context=text(r,"gdc_context"),state=context.startsWith("SENDING:")?"SENDING":context.startsWith("RETRY:")?"RETRY":context;return fields("id",id(r,"gdc_id"),"type",type,"state",state,"attempts",p.attempts(),"message",p.error(),"revision",revision(r));}).toList();} + void retry(long headquarters,String type,long object,long row,String revision){ + if(!enabled)throw bad("Der Abgleich persönlicher Links ist ausgeschaltet."); + var r=one(db.lockRows("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngmeta' AND gdc_obj_id=? AND gdc_gen_fieldname=? AND gdc_id=?",object,type,row)); + if(decode(r).headquarters()!=headquarters)throw bad("Die Objektvormerkung gehört zu einer anderen Niederlassung."); + String context=text(r,"gdc_context");if(context.equals("PENDING")||context.equals("DONE"))return; + unchanged(r,revision);if(!context.equals("BLOCKED")&&!context.startsWith("RETRY:"))throw bad("Dieser Abgleich kann derzeit nicht erneut vorgemerkt werden."); + db.update("UPDATE genericdatacontainer SET gdc_context='PENDING' WHERE gdc_id=?",row); + } + @Scheduled(initialDelay=60000,fixedDelay=30000) + public synchronized void run(){if(!enabled)return;long now=clock.instant().getEpochSecond()+1;for(var row:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type IN ('ngmeta','ngmetadel') AND (gdc_context='PENDING' OR (gdc_context LIKE 'RETRY:%' AND gdc_context scope(LegacyUser u,String kind,long object,long customer){ + if(object<=0)throw bad("Bitte zuerst den Datensatz anlegen."); + if(kind.equals("customers")||kind.equals("couriers")){ + masters.access(u,kind);var p=masters.load(u,kind,object,false); + var refs=new ArrayList();refs.add(new ObjectRef(u.headquartersId(),kind.equals("customers")?"cs":"cr",object));if(kind.equals("couriers"))refs.add(new ObjectRef(u.headquartersId(),"usr",id(p,"usr_id")));return refs; + } + Map person=switch(kind){ + case "employees"->employees.snapshot(u,object).person(); + case "portal-employees"->portal.state(u,customer,object).person(); + case "customer-employees"->customerEmployees.state(u,customerEmployees.context(u,customer),object).person(); + default->throw bad("Unbekannter Bereich persönlicher Links."); + };return List.of(new ObjectRef(id(person,"hq_id"),"usr",id(person,"usr_id"))); + } + Map view(LegacyUser u,String kind,long object,long customer){return registrations.db.readTransaction(()->{ + var items=new ArrayList>();for(var ref:scope(u,kind,object,customer))items.addAll(registrations.status(ref.headquarters(),ref.type(),ref.object())); + return fields("items",items,"enabled",registrations.enabled); + });} + Map retry(LegacyUser u,String kind,long object,long customer,Retry r){ + if(r==null||r.row()<=0)throw bad("Die Vormerkung fehlt.");registrations.support.transactional("genericdatacontainer");registrations.db.writeTransaction(()->{ + if(kind.equals("customer-employees")){var state=customerEmployees.state(u,customerEmployees.context(u,customer),object);if(!CustomerEmployees.canProfile(state)&&!CustomerEmployees.canPassword(u,state))throw CustomerPortalScope.denied();} + var refs=scope(u,kind,object,customer);for(var ref:refs)if(registrations.status(ref.headquarters(),ref.type(),ref.object()).stream().anyMatch(row->id(row,"id")==r.row())){registrations.retry(ref.headquarters(),ref.type(),ref.object(),r.row(),r.revision());return null;} + throw bad("Die Vormerkung gehört nicht zu diesem Datensatz."); + });return view(u,kind,object,customer); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/Models.java b/backend/src/main/java/de/stadtbote/votian/Models.java index 5eb5ce4..b801b87 100644 --- a/backend/src/main/java/de/stadtbote/votian/Models.java +++ b/backend/src/main/java/de/stadtbote/votian/Models.java @@ -8,7 +8,7 @@ import java.util.*; public final class Models { private Models() {} public enum OrderStatus { DISPOSITION, OFFERED, IN_PROGRESS, COMPLETED, CANCELLED } - public enum StopType { PICKUP, DELIVERY, BOTH } + public enum StopType { PICKUP, DELIVERY, BOTH, NONE } public enum CourierStatus { AVAILABLE, BUSY, OFFLINE } public enum CancelMode { FREE, CHARGEABLE } public static class Customer { @@ -26,8 +26,15 @@ public final class Models { public boolean active; } public static class DeliveryOrder { + public long headquartersId; + public String headquartersLabel; + public Map listCells=new LinkedHashMap<>(); + public Long listPayerHeadquarters; + public OrderListAppearance.Appearance listAppearance; + public Long id, version, customerId, payerCustomerId, ordererCustomerId, courierId, invoiceId; public String customerName; + public String courierRemark,courierInternalRemark; public String number; public OrderStatus status; public int legacyStatus, legacyStorno; @@ -60,7 +67,8 @@ public final class Models { } public record Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices, boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, - boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages, boolean manageForms) { + boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages, boolean manageForms, boolean readTransfers) { + public Capabilities(boolean readOrders,boolean readCustomers,boolean readCouriers,boolean readInvoices,boolean writeOrders,boolean writeCustomers,boolean writeCouriers,boolean writeInvoices,boolean completeStops,boolean exportOrders,boolean createOrders,boolean readStatistics,boolean readAppointments,boolean readMessages,boolean manageForms){this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,readAppointments,readMessages,manageForms,false);} public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices, boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages) { this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,readAppointments,readMessages,false); diff --git a/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java b/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java index 45f4059..b3311cd 100644 --- a/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java +++ b/backend/src/main/java/de/stadtbote/votian/NewsletterTemplate.java @@ -17,8 +17,30 @@ class NewsletterTemplate { NewsletterTemplate(@Value("${app.legacy-url:}")String legacy,NewsletterImages images){this.legacy=legacy.replaceAll("/+$","");this.images=images;} 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){if(value.isBlank())return "";String[] p=imageParts(value);String local=images.data(p[0]);String src=local!=null?local:legacy+"/documents/images/"+org.springframework.web.util.UriUtils.encodePathSegment(p[0],java.nio.charset.StandardCharsets.UTF_8);return "\""+escape(p[3])+"\""+(p[1].isBlank()?"":"";} - @SuppressWarnings("unchecked") String render(Map rendering){var defs=(List>)rendering.get("definitions");var data=(Map)rendering.get("data");var vals=(Map)data.get("values");String template=(String)rendering.get("template");var byKey=new HashMap();for(var d:defs){String type=text(d,"type"),v=vals.getOrDefault(id(d,"id"),"");String output=switch(type){case "image"->image(v);case "checkbox"->v.equals("1")?"✓":"";case "text"->Jsoup.clean(v.replace("\n","
"),Safelist.basic());default->escape(v);};byKey.put(id(d,"keyId"),output);}if(template.isBlank()){var out=new StringBuilder();for(var d:defs)out.append("

").append(escape(text(d,"label"))).append("

").append(byKey.get(id(d,"keyId"))).append("
");return clean(out.toString());} + 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 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(); + if(!Set.of("float_2","integer").contains(type)||raw.isEmpty()||raw.length()>1024||!raw.matches("[+-]?(?:[0-9]+(?:[.][0-9]*)?|[.][0-9]+)(?:[eE][+-]?[0-9]+)?"))return escape(value); + try{ + var n=new java.math.BigDecimal(raw); + // Preserve oversized legacy values rather than allocating an unbounded formatted result. + if(Math.abs((long)n.scale())>1000||n.precision()-(long)n.scale()>1000)return escape(value); + var format=new java.text.DecimalFormat(type.equals("float_2")?"#,##0.00":"#,##0",java.text.DecimalFormatSymbols.getInstance(Locale.GERMANY)); + format.setRoundingMode(java.math.RoundingMode.HALF_UP); + return escape(format.format(n.setScale(type.equals("float_2")?2:0,java.math.RoundingMode.HALF_UP))); + }catch(NumberFormatException e){return escape(value);} + } + static String dateTime(String value){ + if(!value.matches("[0-9]{2}\\.[0-9]{2}\\.[0-9]{4} ?[0-9]{2}:[0-9]{2}"))return escape(value); + String date=value.substring(0,10),time=value.substring(value.length()-5); + try{ + EvaluationService.parseDate(date);CustomerServiceSettings.time(time); + return escape(date+" "+time); + }catch(org.springframework.web.server.ResponseStatusException e){return escape(value);} + } + @SuppressWarnings("unchecked") String render(Map rendering){var defs=(List>)rendering.get("definitions");var data=(Map)rendering.get("data");var vals=(Map)data.get("values");String template=(String)rendering.get("template");var byKey=new HashMap();for(var d:defs){String type=text(d,"type"),v=vals.getOrDefault(id(d,"id"),"");String output=switch(type){case "datetime","triggerdatetime"->dateTime(v);case "float_2","integer"->number(v,type);case "image"->image(v);case "system_image"->image(v,true);case "link"->link(v);case "checkbox"->v.equals("1")?"✓":"";case "text"->Jsoup.clean(v.replace("\n","
"),Safelist.basic());default->escape(v);};byKey.put(id(d,"keyId"),output);}if(template.isBlank()){var out=new StringBuilder();for(var d:defs)out.append("

").append(escape(text(d,"label"))).append("

").append(byKey.get(id(d,"keyId"))).append("
");return clean(out.toString());} var matcher=Pattern.compile("\\[-([0-9]{1,10})(?:,[^\\]]*)?-]").matcher(template);var out=new StringBuffer();while(matcher.find())matcher.appendReplacement(out,Matcher.quoteReplacement(byKey.getOrDefault(Long.parseLong(matcher.group(1)),"")));matcher.appendTail(out);String html=out.toString();if(html.contains("[-"))throw bad("Die Newslettervorlage enthält nicht unterstützte Spezialplatzhalter."); // PHP removes an entire numbered container when its CHK_EMPTY element is empty. for(int pass=0;pass<1000;pass++){String before=html;var check=Pattern.compile("(?i)").matcher(html);var empty=new HashSet();String upper=html.toUpperCase(Locale.ROOT);while(check.find()){int end=upper.indexOf("",check.end());if(end>=0){String content=html.substring(check.end(),end);if(Jsoup.parse(content).text().isBlank()&&!content.toLowerCase(Locale.ROOT).contains(".*?","");if(html.equals(before))break;} diff --git a/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java b/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java new file mode 100644 index 0000000..ec8f10a --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OperationsMonitoring.java @@ -0,0 +1,131 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.time.*; +import java.util.*; +import java.util.function.Supplier; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Read-only counterpart of the process/database panels in sysadmin/monitor.php. + * Shared keepalive rows cannot identify which PHP or NG instance wrote them. */ +@RestController +@RequestMapping("/api/administration/operations") +class OperationsMonitoring { + final DatabaseService db; + final BusinessSupport support; + final OrderCompletionProcessing completion; + final RecurringOrderContinuation recurring; + final AutomaticOrderMail mail; + final AutomaticOffers offers; + final OrderOfferRevocation revocation; + final LonghaulReturns returns; + final Instant started; + final Clock clock; + + @org.springframework.beans.factory.annotation.Autowired + OperationsMonitoring(DatabaseService db,BusinessSupport support,OrderCompletionProcessing completion, + RecurringOrderContinuation recurring,AutomaticOrderMail mail,AutomaticOffers offers, + OrderOfferRevocation revocation,LonghaulReturns returns) { + this(db,support,completion,recurring,mail,offers,revocation,returns,Clock.systemUTC()); + } + OperationsMonitoring(DatabaseService db,BusinessSupport support,OrderCompletionProcessing completion, + RecurringOrderContinuation recurring,AutomaticOrderMail mail,AutomaticOffers offers, + OrderOfferRevocation revocation,LonghaulReturns returns,Clock clock) { + this.db=db;this.support=support;this.completion=completion;this.recurring=recurring;this.mail=mail; + this.offers=offers;this.revocation=revocation;this.returns=returns;this.clock=clock;this.started=clock.instant(); + } + static boolean allowed(LegacyUser u) { + return u.hasRight(3)&&u.headquartersId()>0&&u.headquartersId()==u.originalHeadquartersId(); + } + record Worker(String id,String label,boolean configured,String state,String message, + String scope,String lastRun,long ageSeconds,int intervalSeconds,Map values) {} + + static Worker worker(String id,String label,boolean configured,String blocked,String scope,LocalDateTime at, + ZoneId zone,int interval,Map values,Instant now,Instant started) { + Instant last=at==null?null:at.atZone(zone).toInstant(); + long age=last==null?-1:Duration.between(last,now).getSeconds(); + String state,message; + if(!configured){state="DISABLED";message="Dieser NG-Dienst ist ausgeschaltet.";} + else if(!blocked.isBlank()){state="BLOCKED";message=blocked;} + else if(last==null){state=Duration.between(started,now).getSeconds()>180?"NO_RUN":"STARTING";message="Seit dem Start dieser NG-Instanz ist noch kein abgeschlossener Lauf verzeichnet.";} + else if(age< -5){state="CLOCK";message="Der letzte Lauf liegt in der Zukunft. Die Serverzeit muss geprüft werden.";} + else if(values.getOrDefault("failed",0)>0||values.getOrDefault("uncertain",0)>0){state="ERROR";message="Der letzte Lauf enthält Fehler oder unbestätigte Mailübergaben.";} + else if(age>Math.max(180,interval*3)){state="STALE";message="Seit mehr als drei Minuten ist kein abgeschlossener Lauf verzeichnet. Ein noch laufender Vorgang ist möglich.";} + else{state="OK";message="Der letzte Lauf wurde ohne gemeldete Fehler abgeschlossen.";} + return new Worker(id,label,configured,state,message,scope,last==null?"":last.toString(),age,interval,values); + } + List workers(Instant now) { + var zone=ZoneId.systemDefault(); + // Read each volatile immutable result once so one response never mixes two runs. + var c=completion.lastRun;var r=recurring.lastRun;var m=mail.lastRun; + var o=offers.lastRun;var v=revocation.lastRun;var b=returns.monitoringRun(); + return List.of( + worker("completion","Abschlussverarbeitung",completion.enabled, + !completion.phpStopped?"Die Abschaltung der PHP-Abschlussverarbeitung ist nicht bestätigt.":completion.headquarters.isEmpty()?"Keine Niederlassung ist für die Abschlussverarbeitung freigegeben.":"", + scope(completion.headquarters),c.at(),zone,30,Map.of("checked",c.checked(),"processed",c.processed(),"failed",c.failed()),now,started), + worker("recurring","Daueraufträge",recurring.enabled,"","Alle konfigurierten Niederlassungen", + r.at(),recurring.clock.getZone(),60,Map.of("checked",r.checked(),"created",r.created(),"failed",r.failed()),now,started), + worker("mail","Status-E-Mails",mail.enabled,mail.headquarters.isEmpty()?"Keine Niederlassung ist für Status-E-Mails freigegeben.":!mail.transport.configured()?"Der SMTP-Versand ist nicht vollständig konfiguriert.":"", + scope(mail.headquarters),m.at().isBlank()?null:LocalDateTime.parse(m.at()),zone,30, + Map.of("checked",m.checked(),"sent",m.sent(),"uncertain",m.uncertain(),"failed",m.failed()),now,started), + worker("offers","Automatische Angebote",offers.enabled,"","Alle konfigurierten Dispositionsniederlassungen", + o.at(),zone,15,Map.of("checked",o.checked(),"offered",o.offered(),"fallback",o.manualOrGlobal(),"failed",o.failed()),now,started), + worker("revocation","Angebotsentzug",revocation.enabled,"","Alle konfigurierten Dispositionsniederlassungen", + v.at(),zone,15,Map.of("checked",v.checked(),"recalled",v.recalled(),"unlocked",v.unlocked(),"failed",v.failed()),now,started), + worker("returns","BWV-Retouren",returns.monitoringEnabled(),"","Alle konfigurierten Niederlassungen", + b.at(),zone,60,Map.of("checked",b.checked(),"processed",b.processed(),"failed",b.failed()),now,started)); + } + static String scope(Set ids){return ids.isEmpty()?"Keine Niederlassung": "Niederlassungen "+String.join(", ",ids.stream().sorted().map(String::valueOf).toList());} + @GetMapping Map view(Authentication auth){return view(ApiController.user(auth));} + Map view(LegacyUser u) { + if(!allowed(u))throw new AccessDeniedException("Der Betriebsstatus benötigt die Mitarbeiterverwaltung in der ursprünglichen Niederlassung."); + var now=clock.instant(); + return fields("at",now.toString(),"startedAt",started.toString(),"headquartersId",u.headquartersId(), + "workers",workers(now),"signals",read(this::signals), + "database",read(()->database(u.headquartersId())),"history",read(()->history(u.headquartersId()))); + } + Map read(Supplier> source) { + try{return fields("available",true,"data",db.readTransaction(source));} + catch(RuntimeException e){return fields("available",false,"message","Die Datenquelle konnte nicht vollständig gelesen werden. Verbindung und Schema prüfen.");} + } + Map signals() { + var now=db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime(); + var rows=db.queryForList("SELECT ka_process,ka_lastexecutiontime FROM keepalive WHERE ka_process IN ('standing_orders','autoranking') LIMIT 101"); + return fields("databaseTime",now.toString(),"items",List.of(signal("standing_orders","Dauerauftragsdienst",rows,now),signal("autoranking","Vermittlungsdienst",rows,now))); + } + static Map signal(String key,String label,List> rows,LocalDateTime now) { + var matches=rows.stream().filter(r->text(r,"ka_process").equals(key)).toList(); + String state;LocalDateTime at=null;Long age=null; + if(matches.isEmpty())state="MISSING"; + else if(matches.size()!=1||rows.size()>100)state="AMBIGUOUS"; + else { + try{at=dateTime(matches.getFirst(),"ka_lastexecutiontime");}catch(RuntimeException ignored){} + if(at==null)state="UNKNOWN"; + else{age=Duration.between(at,now).getSeconds();state=age< -5?"CLOCK":age>180?"OLD":"RECENT";} + } + return fields("id",key,"label",label,"state",state,"at",at==null?"":at.toString(),"ageSeconds",age); + } + Map database(long hq) { + var day=db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().toLocalDate(); + var from=day.atStartOfDay();var to=day.plusDays(1).atStartOfDay(); + var jobs=db.queryForList(""" + SELECT COUNT(*) AS total,MAX(jb_id) AS latest, + COALESCE(SUM(jb_status=2 AND COALESCE(jb_storno,0) IN (0,1,3) AND jb_finishtime>=? AND jb_finishtime=? AND jb_ordertime0),0) AS reserved FROM courier WHERE hq_id=?",hq).getFirst(); + return fields("day",day.toString(),"jobs",jobs,"couriers",couriers); + } + Map history(long hq) { + var day=db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().toLocalDate(); + Long count=db.queryForObject("SELECT COUNT(*) FROM "+support.historySchema+".log WHERE hq_id=? AND logo_id=4 AND log_createtime>=? AND log_createtime parameters,Map raw,List parts,String revision) { + Map job(Map current){ + if(!Objects.equals(raw.get("jb_jb_id"),Long.toString(id(current,"jb_id"))))throw bad("Der Archivsnapshot gehört nicht zu diesem Auftrag."); + var out=new LinkedHashMap<>(current); + for(var key:Map.of("cscIdPayer","csc_id_payer","jobCourierId","cr_id","job_status","jb_status","job_id_parent","jb_id_parent").entrySet()){ + String value=parameters.getOrDefault(key.getKey(),""); + if(value.isBlank()&&Set.of("jobCourierId","job_id_parent").contains(key.getKey()))value="0"; + if(!(value.matches("[0-9]{1,10}")||(key.getKey().equals("job_id_parent")&&value.equals("-1"))))throw bad("Eine Auftragskennung im Archivsnapshot ist ungültig.");out.put(key.getValue(),Long.parseLong(value)); + } + if(!parameters.getOrDefault("csIdPayer","").matches("[1-9][0-9]{0,9}"))throw bad("Der Rechnungskunde fehlt im Archivsnapshot."); + out.put("cr_sid",parameters.getOrDefault("job_cr_sid",""));String time=parameters.getOrDefault("job_ordertime","");try{out.put("jb_ordertime",LocalDateTime.parse(time.replace(' ','T')));}catch(DateTimeException e){throw bad("Die Auftragszeit im Archivsnapshot ist ungültig.");} + return out; + } + int stations(){String value=raw.getOrDefault("tr_numberofstations","");if(!value.matches("[1-9][0-9]{0,2}")||Integer.parseInt(value)>100)throw bad("Die Anzahl der archivierten Stationen ist ungültig.");return Integer.parseInt(value);} + } + final DatabaseService db; + OrderArchive(DatabaseService db){this.db=db;} + Snapshot load(long order){ + var pending=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='ngarchive' AND gdc_obj_id=? AND gdc_context='PENDING'",order); + if(!pending.isEmpty()){var payload=new AccountingExportArchive(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules())).payload(AdminSupport.one(pending));String raw=inflate(payload.raw()),mail=inflate(payload.mail());List parts=Arrays.asList(mail.split("-;-",-1));if(parts.size()!=6)throw bad("Die gesicherte historische Auftragskopie ist unvollständig.");return new Snapshot("Gesicherter Abrechnungsstand",pairs(parts.getFirst()),pairs(raw),List.copyOf(parts),LegacyOrderPricing.hash(raw+"\n"+mail));} + + for(String table:ordered(db.orderArchiveTables())){var snapshot=read(false,table,order);if(snapshot!=null)return snapshot;} + var tables=db.queryArchiveForList("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND (TABLE_NAME='job2' OR TABLE_NAME REGEXP '^job2_[0-9]{4}$') ORDER BY TABLE_NAME DESC").stream().map(r->text(r,"name")).toList(); + for(String table:ordered(tables)){var snapshot=read(true,table,order);if(snapshot!=null)return snapshot;} + return null; + } + static List ordered(List tables){return tables.stream().filter(t->t.matches("job2(?:_[0-9]{4})?")).distinct().sorted((a,b)->a.equals(b)?0:a.equals("job2")?-1:b.equals("job2")?1:b.compareTo(a)).toList();} + Snapshot read(boolean remote,String table,long order){ + if(!table.matches("job2(?:_[0-9]{4})?"))throw bad("Ungültige Archivtabelle.");String sizes="SELECT OCTET_LENGTH(jb2_history_rawdata) AS rawSize,OCTET_LENGTH(jb2_history_mail) AS mailSize FROM `"+table+"` WHERE jb_id=? LIMIT 2"; + var rows=remote?db.queryArchiveForList(sizes,order):db.queryForList(sizes,order);if(rows.isEmpty())return null;if(rows.size()!=1)throw bad("Der historische Auftragssnapshot ist mehrdeutig."); + long rawSize=id(rows.getFirst(),"rawSize"),mailSize=id(rows.getFirst(),"mailSize");if(rawSize==0)return null;if(rawSize>MAX_COMPRESSED||mailSize>MAX_COMPRESSED)throw bad("Der komprimierte Auftragssnapshot ist zu groß.");if(mailSize==0)throw bad("Der archivierte Auftrag enthält noch keinen lesbaren Kunden-Mail-Snapshot."); + String sql="SELECT jb2_history_rawdata,jb2_history_mail FROM `"+table+"` WHERE jb_id=? AND OCTET_LENGTH(jb2_history_rawdata)<=? AND OCTET_LENGTH(jb2_history_mail)<=? LIMIT 2";rows=remote?db.queryArchiveForList(sql,order,MAX_COMPRESSED,MAX_COMPRESSED):db.queryForList(sql,order,MAX_COMPRESSED,MAX_COMPRESSED); + if(rows.size()!=1)throw bad("Der Auftragssnapshot wurde während des Lesens geändert.");String raw=inflate(rows.getFirst().get("jb2_history_rawdata")),mail=inflate(rows.getFirst().get("jb2_history_mail")); + List parts=Arrays.asList(mail.split("-;-",-1));if(parts.size()!=6)throw bad("Die gespeicherten Mailbestandteile sind unvollständig oder mehrdeutig.");String source=(remote?"Archivserver / ":"Bestandsdatenbank / ")+table; + return new Snapshot(source,pairs(parts.getFirst()),pairs(raw),List.copyOf(parts),LegacyOrderPricing.hash(source+"\n"+raw+"\n"+mail)); + } + static String inflate(Object value){ + if(!(value instanceof byte[] bytes)||bytes.length==0||bytes.length>MAX_COMPRESSED)throw bad("Der Auftragssnapshot enthält ungültige Binärdaten."); + var inflater=new Inflater(true);try{inflater.setInput(bytes);var out=new ByteArrayOutputStream();byte[] buffer=new byte[8192]; + while(!inflater.finished()){int n=inflater.inflate(buffer);if(n==0&&!inflater.finished())throw bad("Der Auftragssnapshot ist beschädigt oder unvollständig.");if(out.size()+n>MAX_INFLATED)throw bad("Der entpackte Auftragssnapshot überschreitet 8 MB.");out.write(buffer,0,n);} + if(inflater.getRemaining()!=0)throw bad("Der Auftragssnapshot enthält unerwartete Zusatzdaten.");byte[] decoded=out.toByteArray(); + try{return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(decoded)).toString();}catch(CharacterCodingException ignored){return new String(decoded,Charset.forName("windows-1252"));} + }catch(DataFormatException e){throw bad("Der Auftragssnapshot lässt sich nicht entpacken.");}finally{inflater.end();} + } + static Map pairs(String raw){ + var result=new LinkedHashMap();for(String item:raw.split("\\|(?=[A-Za-z_][A-Za-z0-9_]*=)",-1)){if(item.isBlank())continue;int i=item.indexOf('=');if(i<1)throw bad("Die Auftragsfelder im Archiv sind beschädigt.");String key=item.substring(0,i),value=item.substring(i+1).strip();if(!key.matches("[A-Za-z_][A-Za-z0-9_]{0,100}"))throw bad("Ungültiges Archivfeld.");String previous=result.putIfAbsent(key,value);if(previous!=null&&!previous.equals(value))throw bad("Widersprüchliche Werte im Archivfeld "+key+".");}return Collections.unmodifiableMap(result); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderArchiveMail.java b/backend/src/main/java/de/stadtbote/votian/OrderArchiveMail.java new file mode 100644 index 0000000..b2ae9fd --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderArchiveMail.java @@ -0,0 +1,69 @@ +package de.stadtbote.votian; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.safety.Safelist; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderCustomerMailContent.*; + +/** Render only the archived customer-mail parts, never the private raw/HQ snapshot. */ +final class OrderArchiveMail { + final OrderCustomerMailContent content;final OrderDeliveryData evidence;final long customer;final String flags; + OrderArchiveMail(OrderCustomerMailContent content,OrderDeliveryData evidence,long customer,String flags){this.content=content;this.evidence=evidence;this.customer=customer;this.flags=flags;} + static String normalizedLabel(String text){return text.replace('\u00a0',' ').replace(":","").strip().toLowerCase(Locale.GERMAN);} + String label(Element cell){return content.language.archiveLabel(normalizedLabel(cell.text()));} + static boolean hasGermanStations(String html){return fragment(html).select("tr").stream().anyMatch(r->!cells(r).isEmpty()&&normalizedLabel(cells(r).getFirst().text()).equals("firma"));} + static List rows(Element table){return table.select("tr").stream().filter(r->r.closest("table")==table).toList();} + static List cells(Element row){return row.children().stream().filter(c->Set.of("td","th").contains(c.tagName())).toList();} + static Element fragment(String html){ + var body=Jsoup.parseBodyFragment(""+OrderDeliveryData.publicRemark(html.replace("\\'","'"))+"
").body(); + body.select("script,style,iframe,object,embed,form,input,button,meta,link,base,svg,math").remove();return body; + } + static String clean(String html){return Jsoup.clean(html,Safelist.relaxed().removeTags("img").addProtocols("a","href","mailto"));} + String sectionPart(String title,Element body){String html=clean(body.html());return body.text().isBlank()?"":"

"+escape(content.t(title+":"))+"

"+html;} + String render(OrderArchive.Snapshot archive,Map job,boolean prices){ + long order=id(job,"jb_id");var out=new StringBuilder(sectionPart("AUFTRAG",fragment(archive.parts().get(1)))); + var courier=fragment(archive.parts().get(4)); + for(var r:new ArrayList<>(courier.select("tr"))){var c=cells(r);if(c.size()<2)continue;String name=label(c.getFirst());if(!Set.of("sid","mobil-nr.","kennzeichen").contains(name))throw bad("Eine archivierte Transporteurbezeichnung wird nicht erkannt.");if(name.equals("mobil-nr.")&&!bit(flags,1)||name.equals("kennzeichen")&&!bit(flags,6))r.remove();} + if(id(job,"jb_status")!=9)out.append(sectionPart("TRANSPORTEUR",courier)); + var part=fragment(archive.parts().get(2));int count=archive.stations(); + var tables=part.select("table").stream().filter(t->rows(t).stream().anyMatch(r->!cells(r).isEmpty()&&label(cells(r).getFirst()).equals("firma"))).toList(); + boolean classic=tables.size()==count&&tables.stream().allMatch(t->rows(t).stream().allMatch(r->cells(r).size()==2)); + if(!classic&&(tables.size()!=1||rows(tables.getFirst()).stream().anyMatch(r->cells(r).size()!=count+1)))throw bad("Das archivierte Stationsformat ist unvollständig oder wird nicht erkannt."); + var positions=new TreeSet();for(String key:archive.raw().keySet())if(key.matches("tr_[0-9]+_tr_comp")){String number=key.substring(3,key.length()-8);try{int n=Integer.parseInt(number);if(n<1||n>1000)throw new NumberFormatException();positions.add(n);}catch(NumberFormatException e){throw bad("Eine archivierte Stationsnummer ist ungültig.");}} + if(positions.size()!=count)throw bad("Die archivierten Stationen lassen sich nicht eindeutig zuordnen."); + var current=content.db.queryForList("SELECT t.tr_id,t.tr_sort FROM tour t WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",order);var ids=new HashMap(); + for(var stop:current)if(ids.put((int)id(stop,"tr_sort"),id(stop,"tr_id"))!=null)throw bad("Die aktuellen Stationen des Archivauftrags sind mehrdeutig."); + out.append("

").append(escape(content.t("TOUR:"))).append("

");int column=0; + for(int position:positions){out.append("");var stationRows=rows(tables.get(classic?column:0)); + for(var r:stationRows){var c=cells(r);var heading=c.getFirst();var value=c.get(classic?1:column+1);String name=label(heading); + if(!visible(name))continue; + if(name.equals("unterschrift")){ + String raw=archive.raw().getOrDefault("tr_"+position+"_tr_sign","");var image=OrderDeliveryData.signature(raw); + if(image==null&&!value.select("img").isEmpty())throw bad("Die archivierte Unterschrift für Station "+position+" enthält keine lesbaren Koordinaten."); + if(image!=null)out.append(OrderMailEvidence.image(heading.text(),image)); + }else if(name.equals("abliefernachweis foto")){ + Long station=ids.get(position);if(station==null)throw bad("Die Fotos der archivierten Station "+position+" lassen sich nicht mehr zuordnen."); + var pictures=evidence.pictures(station);if(pictures.isEmpty()&&!value.select("img").isEmpty())throw bad("Ein archiviertes Stationsfoto ist nicht mehr verfügbar."); + for(var picture:pictures){out.append(OrderMailEvidence.image(heading.text(),evidence.image(station,picture.key())));bounded(out);} + }else{out.append("");} + bounded(out); + }out.append("
").append(escape(heading.text())).append("").append(clean(value.html())).append("

");column++; + } + if(prices){var costs=fragment(archive.parts().get(3));if(!bit(flags,2))for(var r:new ArrayList<>(costs.select("tr"))){var c=cells(r);if(!c.isEmpty()&&label(c.getFirst()).equals("rechnungstext"))r.remove();}out.append(sectionPart("KOSTEN",costs));} + bounded(out);return out.toString(); + } + boolean visible(String label){return switch(label){ + case "bemerkung"->bit(flags,0); + case "kostenstelle"->!content.object("JOB_MAIL_STATION_COSTCENTER_DISABLED",customer).equals("1"); + case "station"->content.object("JOBDETAILS_STATIONS_STATION_NUMBER",customer).equals("1"); + case "ankunftszeit"->content.object("JOB_MAIL_STATION_ARRIVAL_TIME",customer).equals("1"); + case "bemerkung kurier"->bit(flags,8)&&content.fallback("MASK_JOBDETAILS_STATIONS_POD_SCAN_EVENTS").equals("1"); + case "bemerkung innendienst"->bit(flags,9)&&(content.local("MASK_JOBDETAILS_INTERNAL_REMARK_ITEMS").equals("1")||content.parameter(0,"MASK_JOBDETAILS_INTERNAL_REMARK_ITEMS").equals("1")); + case "", "firma", "person", "straße + hausnr.", "plz + ort", "eid", "tel.", "kommissionsnummer", "erledigungszeit", "quittungsgeber", "unterschrift", "abliefernachweis foto"->true; + default->throw bad("Eine archivierte Stationsbezeichnung wird nicht erkannt: "+label); + };} + static void bounded(StringBuilder out){if(out.length()>12_000_000)throw bad("Die archivierte Auftragsmail einschließlich Nachweisen überschreitet 12 MB.");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderBatches.java b/backend/src/main/java/de/stadtbote/votian/OrderBatches.java new file mode 100644 index 0000000..096ab4e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderBatches.java @@ -0,0 +1,172 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.stereotype.Service; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; +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; +import static de.stadtbote.votian.BusinessSupport.insertWithoutKey; + +/** Native counterpart of jobs/jb_edit_batch.php; all rows are committed together. */ +@Service +public class OrderBatches { + record Row(int line,LocalDateTime finishedAt,String text,String reference,long vehicleId, + BigDecimal freight,BigDecimal service,BigDecimal kilometers,boolean discount,boolean markup) {} + record Command(String requestId,long costCenterId,long batchId,List rows,String fingerprint,boolean calculateCo2) { + Command(String requestId,long costCenterId,long batchId,List rows,String fingerprint){this(requestId,costCenterId,batchId,rows,fingerprint,false);} + // Existing receipts hash record text; preserve the old representation for unchanged requests. + @Override public String toString(){return "Command[requestId="+requestId+", costCenterId="+costCenterId+", batchId="+batchId+", rows="+rows+", fingerprint="+fingerprint+(calculateCo2?", calculateCo2=true":"")+"]";} + } + record Result(long batchId,int number,List orderIds) {} + record Prepared(Map customer,Map address,Map tax, + int number,int sequence,int jobType,List> vehicles, + List amounts,List invoiceTexts, + List co2Inputs,List commissions,String fingerprint) {} + private final DatabaseService db;private final BusinessSupport support;private final OrderEntryCatalog catalog; + private final LegacyOrderPricing pricing;private final Clock clock;private final OrderSustainability sustainability;private final BatchInvoiceExports exports; + @org.springframework.beans.factory.annotation.Autowired + public OrderBatches(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,LegacyOrderPricing pricing,OrderSustainability sustainability){this(db,support,catalog,pricing,sustainability,Clock.system(ZoneId.of("Europe/Berlin")));} + OrderBatches(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,LegacyOrderPricing pricing,Clock clock){this(db,support,catalog,pricing,new OrderSustainability(catalog,null,null,"","",""),clock);} + OrderBatches(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,LegacyOrderPricing pricing,OrderSustainability sustainability,Clock clock){this.db=db;this.support=support;this.catalog=catalog;this.pricing=pricing;this.clock=clock;this.sustainability=sustainability;this.exports=new BatchInvoiceExports(db,support);} + + private void access(LegacyUser u) { + u.require(7); + if(u.headquartersId()!=u.originalHeadquartersId())throw bad("Die Listenerfassung verwendet die eigene Niederlassung."); + var row=one(db.queryForList("SELECT e.emp_rights FROM user u JOIN employee e ON e.usr_id=u.usr_id WHERE u.usr_id=? AND e.emp_id=? AND u.hq_id=? AND u.usr_type=1",u.userId(),u.employeeId(),u.headquartersId())); + new LegacyUser(u.userId(),u.employeeId(),u.headquartersId(),u.username(),text(row,"emp_rights")).require(7); + catalog.headquarters(u); + } + Map options(LegacyUser u,long center,int offset) { + if(offset<0||offset>1000000)throw bad("Ungültige Listenseite."); + return db.readTransaction(()->{access(u);var customer=catalog.costCenter(u,center,true); + var lists=db.queryForList("SELECT jbb_id AS id,jbb_sort AS number,DATE_FORMAT(jbb_createtime,'%Y-%m-%dT%H:%i:%s') AS createdAt FROM jobbatch WHERE cs_id=? ORDER BY jbb_sort DESC,jbb_id DESC LIMIT 51 OFFSET ?",id(customer,"cs_id"),offset); + return fields("discount",catalog.enabled(u.headquartersId(),"JB_EDITBATCH_DISCOUNT_DISABLED")?BigDecimal.ZERO:OrderEntryCatalog.number(customer,"cs_discount"), + "markup",id(customer,"cs_markup")==1,"courierPricing",catalog.enabled(u.headquartersId(),"MASK_CR_PRICE_MODE"), + "co2Configured",sustainability.configured(),"today",LocalDate.now(clock).toString(),"lists",lists.stream().limit(50).toList(),"hasMore",lists.size()>50); + }); + } + private Map batch(long customer,long batch) { + return one(db.queryForList("SELECT jbb_id,jbb_sort,jbb_createtime FROM jobbatch WHERE jbb_id=? AND cs_id=?",batch,customer)); + } + private List> entries(LegacyUser u,long customer,long batch) { + var rows=db.queryForList(""" + SELECT bl.jbbl_lfd AS sequence,j.jb_id AS id,DATE_FORMAT(j.jb_finishtime,'%Y-%m-%dT%H:%i:%s') AS finishedAt,j.jb_freetext_1 AS text, + j.jb_invtext AS invoiceText,j.jb_invtext_cr AS courierInvoiceText,j.jb_commission_no AS reference,j.cr_sid AS vehicle,j.jb_totalprice AS net,j.jb_cr_price AS courierNet, + j.jb_serviceprice AS service,j.jb_markup AS markup,cc.csc_name AS costCenter,j.jb_storno AS cancelled, + (SELECT MAX(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort=11) AS kilometers, + (SELECT MAX(p2.jbprc_price) FROM jobprice p2 WHERE p2.jb_id=j.jb_id AND p2.mt_sort=12) AS co2eKg + FROM jobbatchlist bl JOIN job j ON j.jb_id=bl.jb_id JOIN costcenter cc ON cc.csc_id=j.csc_id_payer + WHERE bl.jbb_id=? AND j.hq_id=? AND cc.cs_id=? ORDER BY bl.jbbl_lfd,j.jb_id LIMIT 1001 + """,batch,u.headquartersId(),customer); + if(rows.size()>1000)throw bad("Diese Liste hat mehr als 1000 Aufträge. Bitte eine neue Liste beginnen."); + long total=db.queryForObject("SELECT COUNT(*) FROM jobbatchlist WHERE jbb_id=?",Long.class,batch); + if(total!=rows.size())throw bad("Die Liste enthält fremde oder nicht mehr zuordenbare Aufträge und kann hier nicht geöffnet werden."); + return rows; + } + Map view(LegacyUser u,long center,long batchId) { + return db.readTransaction(()->{access(u);long cs=id(catalog.costCenter(u,center,true),"cs_id");var batch=batch(cs,batchId);var rows=entries(u,cs,batchId);exports.attach(u.headquartersId(),rows); + return fields("id",batchId,"number",id(batch,"jbb_sort"),"rows",rows,"nextSequence",nextSequence(rows));}); + } + private static int nextSequence(List> rows){return rows.stream().mapToInt(r->(int)id(r,"sequence")).max().orElse(0)+1;} + static void validate(Command c,LocalDateTime now) { + if(c==null||c.costCenterId()<=0||c.batchId()<0)throw bad("Bitte eine Kostenstelle auswählen."); + BusinessSupport.token(c.requestId()); + if(c.rows()==null||c.rows().isEmpty()||c.rows().size()>20)throw bad("Bitte 1 bis 20 ausgefüllte Zeilen übergeben."); + Set lines=new HashSet<>(); + for(Row r:c.rows()) { + if(r==null||r.line()<1||r.line()>20||!lines.add(r.line()))throw bad("Ungültige oder doppelte Zeilennummer."); + String prefix="Zeile "+r.line()+": "; + if(r.finishedAt()==null||r.finishedAt().getYear()<2000||r.finishedAt().isAfter(now))throw bad(prefix+"Bitte einen bereits vergangenen Erledigungszeitpunkt ab dem Jahr 2000 angeben."); + if(ZoneId.of("Europe/Berlin").getRules().getValidOffsets(r.finishedAt()).size()!=1)throw bad(prefix+"Die Uhrzeit ist wegen der Zeitumstellung nicht eindeutig oder existiert nicht."); + if(r.text()==null||r.text().isBlank()||r.text().length()>2000)throw bad(prefix+"Auftrag / Tourenverlauf muss 1 bis 2000 Zeichen enthalten."); + if(r.reference()==null||r.reference().length()>50)throw bad(prefix+"Die Referenz darf höchstens 50 Zeichen enthalten."); + if(r.vehicleId()<=0)throw bad(prefix+"Bitte ein Fahrzeug auswählen."); + amount(r.freight(),prefix+"Fuhrbetrag");amount(r.service(),prefix+"Servicebetrag");amount(r.kilometers(),prefix+"Kilometer"); + } + } + private static void amount(BigDecimal value,String name){if(value==null||value.signum()<0||value.compareTo(new BigDecimal("99999.99"))>0||value.stripTrailingZeros().scale()>2)throw bad(name+": Bitte 0 bis 99.999,99 mit höchstens zwei Nachkommastellen angeben.");} + private Prepared prepare(LegacyUser u,Command c) { + var customer=catalog.costCenter(u,c.costCenterId(),true);long cs=id(customer,"cs_id"); + var address=one(db.queryForList("SELECT cmp.ad_id,cmp.cmp_comp,cmp.cmp_comp2,cmp.cmp_hsno,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id JOIN address a ON a.ad_id=cmp.ad_id WHERE cs.cs_id=? AND cs.hq_id=?",cs,u.headquartersId())); + var tax=one(db.queryForList("SELECT tx_value,tx_sign FROM tax WHERE tx_id=?",id(customer,"tx_id"))); + int number,sequence;Object batchEvidence; + if(c.batchId()==0){Integer max=db.queryForObject("SELECT COALESCE(MAX(jbb_sort),0) FROM jobbatch WHERE cs_id=?",Integer.class,cs);number=max+1;sequence=1;batchEvidence=number;} + else{var batch=batch(cs,c.batchId());var rows=entries(u,cs,c.batchId());number=(int)id(batch,"jbb_sort");sequence=nextSequence(rows);batchEvidence=List.of(batch,rows);if(rows.size()+c.rows().size()>1000)throw bad("Bitte eine neue Liste beginnen (höchstens 1000 Aufträge je Liste).");} + if(number>8388607||sequence+c.rows().size()>32768)throw bad("Die Listennummer oder laufende Nummer ist ausgeschöpft."); + String type=catalog.parameter(u.headquartersId(),"JB_EDITBATCH_JBTYPE");int jobType=type.isBlank()?0:parseType(type); + var vehicles=new ArrayList>();var amounts=new ArrayList(); + var texts=new ArrayList();var inputs=new ArrayList();var commissions=new ArrayList(); + var settings=BatchInvoiceTexts.settings(db,catalog,u.headquartersId(),customer); + for(var r:c.rows()) { + var vehicle=catalog.vehicle(u,r.vehicleId());vehicles.add(vehicle); + var vehicleType=one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(vehicle,"vht_id"))); + var amount=pricing.batch(u.headquartersId(),customer,text(vehicleType,"mt_value"),r.finishedAt(),r.freight(),r.service(),r.discount(),r.markup());amounts.add(amount); + texts.add(BatchInvoiceTexts.create(r,amount,settings)); + inputs.add(c.calculateCo2()?sustainability.batchInput(u,c.requestId(),r.line(),r.vehicleId(),vehicle,r.kilometers()):null); + var history=c.calculateCo2()&&r.kilometers().signum()>0?db.queryForList("SELECT srvprch_price FROM servicepricehistory WHERE srvprch_obj_type='crvh' AND srvprch_obj_id=? AND srvprch_fieldname='crvh_partner_commission' AND srvprch_validfrom<=? ORDER BY srvprch_validfrom DESC LIMIT 1",r.vehicleId(),r.finishedAt()):List.>of(); + commissions.add(history.isEmpty()?BigDecimal.ZERO:OrderEntryCatalog.number(history.getFirst(),"srvprch_price")); + } + String fingerprint=revision(List.of(c.costCenterId(),c.batchId(),c.rows(),customer,address,tax,batchEvidence,vehicles,amounts,jobType,settings,texts,inputs,commissions)); + return new Prepared(customer,address,tax,number,sequence,jobType,vehicles,amounts,texts,inputs,commissions,fingerprint); + } + private static int parseType(String value){try{int type=Integer.parseInt(value);if(type<0||type>127)throw new NumberFormatException();return type;}catch(NumberFormatException e){throw bad("JB_EDITBATCH_JBTYPE enthält keine gültige Auftragsart.");}} + Map preview(LegacyUser u,Command c) { + u.require(7);validate(c,LocalDateTime.now(clock)); + var p=db.readTransaction(()->{access(u);exports.requireTransactional();return prepare(u,c);}); + // Network work happens after the read transaction, never while booking tables are locked. + var values=p.co2Inputs().stream().map(sustainability::resolveBatch).toList(); + var rows=new ArrayList>();BigDecimal total=BigDecimal.ZERO; + for(int i=0;i{access(u);var receipt=List.of("order-batch",c);Long saved=support.receipt(u,c.requestId(),receipt); + if(saved!=null){try{return support.json.readValue(text(one(db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_obj_type='ngbatch' AND gdc_obj_id=?",saved,u.userId())),"gdc_content"),Result.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);}} + var p=prepare(u,c);var co2=p.co2Inputs().stream().map(sustainability::cachedBatch).toList();if(!revision(List.of(p.fingerprint(),co2)).equals(c.fingerprint()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die Liste, Stammdaten oder Preise haben sich geändert. Bitte die Vorschau erneuern."); + LocalDateTime now=LocalDateTime.now(clock).withNano(0); + long cs=id(p.customer(),"cs_id"),batch=c.batchId()==0?db.insert("jobbatch",fields("cs_id",cs,"jbb_sort",p.number(),"jbb_createtime",now)):c.batchId();var jobs=new ArrayList(); + for(int i=0;i options(Authentication a,@RequestParam long costCenterId,@RequestParam(defaultValue="0") int offset){return batches.options(ApiController.user(a),costCenterId,offset);} + @GetMapping("/{id}") Map view(Authentication a,@PathVariable long id,@RequestParam long costCenterId){return batches.view(ApiController.user(a),costCenterId,id);} + @PostMapping("/preview") Map preview(Authentication a,@RequestBody OrderBatches.Command c){return batches.preview(ApiController.user(a),c);} + @PostMapping OrderBatches.Result save(Authentication a,@RequestBody OrderBatches.Command c){return batches.save(ApiController.user(a),c);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderBillingPayers.java b/backend/src/main/java/de/stadtbote/votian/OrderBillingPayers.java new file mode 100644 index 0000000..7b11aed --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderBillingPayers.java @@ -0,0 +1,34 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; + +/** Only an explicitly configured third payer extends the internal billing scope. */ +final class OrderBillingPayers { + private final DatabaseService db; + OrderBillingPayers(DatabaseService db){this.db=db;} + List> configured(LegacyUser user,long payer) { + user.require(7); + if(user.customerAccount()||payer<=0)return List.of(); + if(db.queryForList("SELECT cs_id FROM customer WHERE hq_id=? AND csc_id_payer=?",user.headquartersId(),payer).isEmpty())return List.of(); + return db.queryForList(OrderEntryCatalog.COST_CENTER_SELECT+ + OrderEntryCatalog.COST_CENTER_FROM.replace("cs.hq_id=?","cs.hq_id<>?")+""" + AND cc.csc_id=? AND cc.csc_is_extern=0 + AND EXISTS (SELECT 1 FROM headquarters target + JOIN mandatorheadquarters dest ON dest.hq_id=target.hq_id + JOIN mandatorheadquarters origin ON origin.md_id=dest.md_id + WHERE target.hq_id=cs.hq_id AND COALESCE(target.hq_disabled,0)=0 + AND origin.hq_id=? AND origin.md_id>0 + AND (SELECT COUNT(*) FROM mandatorheadquarters m WHERE m.hq_id=target.hq_id)=1 + AND (SELECT COUNT(*) FROM mandatorheadquarters m WHERE m.hq_id=origin.hq_id)=1) + AND EXISTS (SELECT 1 FROM customer source + JOIN company sourceCompany ON sourceCompany.cmp_id=source.cmp_id + JOIN costcenter sourceCenter ON sourceCenter.cs_id=source.cs_id + WHERE source.hq_id=? AND source.csc_id_payer=cc.csc_id + AND sourceCompany.cmp_visible=1 AND sourceCompany.cmp_authenticated=1 + AND COALESCE(sourceCompany.cmp_archived,0)=0 + AND sourceCenter.csc_visible=1 AND COALESCE(sourceCenter.csc_blocked,0)=0 + AND sourceCenter.csc_is_extern=0) + """,user.headquartersId(),payer,user.headquartersId(),user.headquartersId()); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderBookingDate.java b/backend/src/main/java/de/stadtbote/votian/OrderBookingDate.java new file mode 100644 index 0000000..8a87c25 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderBookingDate.java @@ -0,0 +1,22 @@ +package de.stadtbote.votian; + +import java.time.LocalDateTime; +import java.util.Map; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.LegacyMapping.dateTime; + +/** PHP jb_detail: only the order's finish date changes; booking and proof times remain current. */ +final class OrderBookingDate { + static final String PARAMETER="MASK_JOBDETAILS_BOOKJOB_DATE"; + static String defaultMode(OrderEntryCatalog catalog,long headquarters) { + return catalog.enabled(headquarters,PARAMETER)?"ORDER":"CURRENT"; + } + static void validate(String action,String mode) { + if(mode!=null&&(!"COMPLETE".equals(action)||!("CURRENT".equals(mode)||"ORDER".equals(mode)))) + throw bad("Das Abschlussdatum kann nur beim vollständigen Auftragsabschluss als Tages- oder Auftragsdatum gewählt werden."); + } + static LocalDateTime finishedAt(Map job,String mode,LocalDateTime now) { + var ordered=dateTime(job,"jb_ordertime"); + return "ORDER".equals(mode)&&ordered!=null?ordered:now; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java b/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java new file mode 100644 index 0000000..0733fb7 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderChildInvoiceTexts.java @@ -0,0 +1,54 @@ +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; + +/** saveJobChildren: both settlement documents inherit the same main-order reference. */ +final class OrderChildInvoiceTexts { + record Policy(int columns,boolean includeCourierText) {} + record Family(OrderOperations.Snapshot main,List pairs,Policy policy) { + long root(){return id(main.job(),"jb_id");} + List members(){return pairs.stream().flatMap(p->java.util.stream.Stream.of(p.positive(),p.negative())).toList();} + } + static Policy policy(OrderEntryCatalog catalog,long headquarters){ + String raw=catalog.parameter(headquarters,"INV_MAXCOLS_EXPORT");int columns=0; + try{if(!raw.isBlank())columns=Integer.parseInt(raw.strip());}catch(NumberFormatException e){throw bad("Die Zeilenbreite der Unternehmerbelege ist ungültig (INV_MAXCOLS_EXPORT).");} + if(columns<0||columns>1000)throw bad("Die Zeilenbreite der Unternehmerbelege muss zwischen 0 und 1000 liegen."); + return new Policy(columns,catalog.enabled(headquarters,"INV_CHILD_NOFREETEXT")); + } + static String render(long main,String courierText,Policy policy){ + String heading="Unternehmerverrechnung zu Auftrag Nr. "+main; + return heading+" ".repeat(Math.max(0,policy.columns()-heading.length()))+"\n"+(policy.includeCourierText()?Objects.toString(courierText,""):""); + } + static Family read(OrderOperations orders,LegacyUser user,OrderOperations.Snapshot current,boolean lock){ + var root=current;var seen=new HashSet();seen.add(id(root.job(),"jb_id"));int depth=0; + while(id(root.job(),"jb_id_parent")>0){ + long parent=id(root.job(),"jb_id_parent");if(!seen.add(parent)||++depth>2)throw bad("Die Unternehmerbelegkette ist zyklisch oder mehrstufig."); + root=orders.snapshot(user,parent,lock); + } + var pairs=new OrderChildRecalculation(orders.db,orders,orders.support).load(user,root,lock); + if(pairs.isEmpty()){if(depth>0)throw bad("Zum Unternehmerbeleg fehlt ein gültiges Verrechnungspaar.");return null;} + var family=new Family(root,pairs,policy(orders.catalog,user.headquartersId())); + if(depth>0){orders.writable(root);if(family.members().stream().noneMatch(m->id(m.job(),"jb_id")==id(current.job(),"jb_id")))throw bad("Der Unternehmerbeleg gehört nicht zum geprüften Hauptauftrag.");} + return family; + } + static List> changes(Family family,long job,String courierText){ + if(family==null||family.root()!=job)return List.of();String next=render(job,courierText,family.policy()); + return family.members().stream().filter(s->!next.equals(text(s.job(),"jb_invtext"))).>map(s->OrderEntryService.fields("id",id(s.job(),"jb_id"),"courier",text(s.job(),"cr_sid"),"previous",text(s.job(),"jb_invtext"),"next",next)).toList(); + } + static void writeChildren(OrderOperations orders,Family family,String courierText){ + if(family==null)return;String text=render(family.root(),courierText,family.policy()); + for(var child:family.members()){ + if(text.equals(text(child.job(),"jb_invtext")))continue; + orders.writable(child); + orders.db.update("UPDATE job SET jb_invtext=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",text,id(child.job(),"jb_id")); + } + } + static void writeMember(OrderOperations orders,long job,OrderInvoicePreparation.Prepared prepared){ + var family=prepared.family();orders.writable(family.main()); + orders.db.update("UPDATE job SET jb_invtext=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",prepared.result().texts().customer(),job); + orders.db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_invtext_dirty'",job); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java b/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java index 59bd0a7..fb46a08 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderChildRecalculation.java @@ -87,14 +87,15 @@ class OrderChildRecalculation { } void writeOne(LegacyUser user,OrderOperations.Snapshot main,OrderOperations.Snapshot child,CourierAllocation a,int sign,Quote q,boolean first,String token){ long id=id(child.job(),"jb_id");BigDecimal factor=BigDecimal.valueOf(sign); + OrderPaymentTerms.preserveLegacySnapshot(db,id); // Keep the original accounting customer rather than silently changing historical relations. if(id(child.job(),"csc_id_payer")!=a.payerId())throw bad("Der Verrechnungszahler hat sich seit der ursprünglichen Buchung geändert."); long courier=sign>0?a.courierId():id(main.job(),"cr_id");String sid=sign>0?a.sid():text(main.job(),"cr_sid"); - update(db,"job","jb_id",id,fields("jb_totalprice",a.net().multiply(factor),"jb_cr_price",a.net().multiply(factor),"jb_fixprice",a.base().multiply(factor),"jb_subtotalprice",a.subtotal().multiply(factor),"jb_cr_subprice",a.subtotal().subtract(a.service()).multiply(factor),"jb_serviceprice",a.service().multiply(factor),"jb_markup",q.markup(),"jb_sales_tax_rate",a.taxRate(),"jb_sales_tax_rate_sign",a.taxSign(),"cr_id",courier,"cr_id_order",courier,"cr_sid",sid,"jb_version",id(child.job(),"jb_version")+1)); + update(db,"job","jb_id",id,fields("jb_type",id(main.job(),"jb_type"),"jb_invtext",OrderChildInvoiceTexts.render(id(main.job(),"jb_id"),text(main.job(),"jb_invtext_cr"),OrderChildInvoiceTexts.policy(orders.catalog,user.headquartersId())),"jb_totalprice",a.net().multiply(factor),"jb_cr_price",a.net().multiply(factor),"jb_fixprice",a.base().multiply(factor),"jb_subtotalprice",a.subtotal().multiply(factor),"jb_cr_subprice",a.subtotal().subtract(a.service()).multiply(factor),"jb_serviceprice",a.service().multiply(factor),"jb_markup",q.markup(),"jb_sales_tax_rate",a.taxRate(),"jb_sales_tax_rate_sign",a.taxSign(),"cr_id",courier,"cr_id_order",courier,"cr_sid",sid,"jb_version",id(child.job(),"jb_version")+1)); db.update("DELETE FROM tourservice WHERE jb_id=?",id); insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",a.payerId(),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis","trs_srvt_name","","trs_price",markup(a.base(),q.markup()).multiply(factor),"trs_discount",a.discount())); if(a.service().signum()>0)insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",a.payerId(),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Servicepreis","trs_srvt_name","","trs_price",a.service().multiply(factor),"trs_discount",0)); - db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id); + db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15 OR mt_sort=16)",id); var prices=Map.of(1,q.markup(),2,a.subtotal().subtract(a.service()).multiply(factor),3,a.service().multiply(factor),10,a.subtotal().multiply(factor)); for(var price:prices.entrySet())insertWithoutKey(db,"jobprice",fields("jb_id",id,"mt_sort",price.getKey(),"jbprc_price",price.getValue(),"jbprc_remark","")); db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','jb_invtext_dirty','ng_calculation_input','ng_calculation_result')",id); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletedVehicle.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletedVehicle.java new file mode 100644 index 0000000..7340903 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletedVehicle.java @@ -0,0 +1,70 @@ +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.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Current jb_detail.php bookjob branch for completed, unexported jobs. */ +@RestController +@RequestMapping("/api/orders/{job}/completed-vehicle") +public final class OrderCompletedVehicle { + final OrderOperations orders;final DatabaseService db;final BusinessSupport support; + public OrderCompletedVehicle(OrderOperations orders){this.orders=orders;db=orders.db;support=orders.support;} + public record Input(long vehicleId){} + public record Command(String requestId,String revision,long vehicleId,boolean confirmed){} + record State(OrderOperations.Snapshot order,Map vehicle,OrderCourierAgio.Source agio, + boolean related,String executionRate,List> markers){} + // Corrections belong to the vehicle's registered transporteur, as in PHP. Live login/ranking is irrelevant. + static final String FROM=" FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id JOIN headquarters hq ON hq.hq_id=cr.hq_id LEFT JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE hq.hq_disabled=0"; + static final String COLUMNS="cv.crvh_id,cv.crvh_sid,cv.crvh_vh_sign,cv.vht_id,cv.crvh_partner_commission,cr.cr_id,cr.hq_id AS executor,cmp.cmp_comp"; + static void access(LegacyUser u){u.require(4);u.require(7);} + OrderOperations.Snapshot eligible(LegacyUser u,long job,boolean lock){ + var s=orders.snapshot(u,job,lock);orders.writable(s); + if(id(s.job(),"jb_status")!=2)throw bad("Der Fahrzeugwechsel ist nur für abgeschlossene Aufträge vorgesehen."); + if(id(s.job(),"jb_id_parent")>0&&!orders.catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))throw bad("Bitte das Fahrzeug am Hauptauftrag ändern."); + return s; + } + @GetMapping("/vehicles") Map getVehicles(Authentication a,@PathVariable long job,@RequestParam(defaultValue="") String q){return vehicles(ApiController.user(a),job,q);} + Map vehicles(LegacyUser u,long job,String q){access(u);if(q==null||q.length()>100)throw bad("Der Suchtext ist zu lang.");return db.readTransaction(()->{ + eligible(u,job,false);String term=OrderListFilters.term(q.strip());String where=FROM+" AND (cv.crvh_sid LIKE ? ESCAPE '!' OR cv.crvh_vh_sign LIKE ? ESCAPE '!' OR cmp.cmp_comp LIKE ? ESCAPE '!')"; + return fields("total",db.queryForObject("SELECT COUNT(*)"+where,Long.class,term,term,term),"limit",200,"items",db.queryForList("SELECT "+COLUMNS+where+" ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 200",term,term,term).stream().map(OrderCompletedVehicle::vehicleView).toList()); + });} + static Map vehicleView(Map v){return fields("id",id(v,"crvh_id"),"sid",text(v,"crvh_sid"),"plate",text(v,"crvh_vh_sign"),"courierId",id(v,"cr_id"),"courierName",text(v,"cmp_comp"),"headquartersId",id(v,"executor"),"vehicleType",id(v,"vht_id"));} + State state(LegacyUser u,long job,long vehicle,boolean lock){ + if(vehicle<=0)throw bad("Bitte ein Fahrzeug wählen.");var s=eligible(u,job,lock); + String sql="SELECT "+COLUMNS+FROM+" AND cv.crvh_id=?";var rows=lock?db.lockRows(sql,vehicle):db.queryForList(sql,vehicle); + if(rows.size()!=1)throw bad("Das Fahrzeug ist keiner aktiven Niederlassung eindeutig zugeordnet.");var v=rows.getFirst(); + if(text(v,"crvh_sid").isBlank()||id(v,"vht_id")<=0)throw bad("Dem Fahrzeug fehlt eine Fahrerkennung oder Fahrzeugklasse."); + String markers="SELECT gdc_id,gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_bwv','jb_third_country') ORDER BY gdc_id"; + return new State(s,v,OrderCourierAgio.read(db,job,lock,id(v,"cr_id")),orders.catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"),orders.catalog.parameter(u.headquartersId(),OrderExecutionAccounting.RATE),lock?db.lockRows(markers,job):db.queryForList(markers,job)); + } + static BigDecimal courierNet(State s){return OrderCourierAgio.calculate(s.agio()).total();} + static OrderExecutionAccounting.Attribution allocation(State s){return OrderExecutionAccounting.calculate(id(s.order().job(),"hq_id"),id(s.vehicle(),"executor"),s.markers().stream().anyMatch(r->text(r,"gdc_gen_fieldname").equals("jb_bwv")&&text(r,"gdc_content").equals("1")),decimal(s.order().job(),"jb_totalprice"),courierNet(s),id(s.order().job(),"hq_id")==id(s.vehicle(),"executor")?BigDecimal.ZERO:OrderExecutionAccounting.rate(s.executionRate()));} + @PostMapping("/preview") Map preview(Authentication a,@PathVariable long job,@RequestBody Input input){if(input==null)throw bad("Bitte ein Fahrzeug wählen.");return preview(ApiController.user(a),job,input.vehicleId());} + Map preview(LegacyUser u,long job,long vehicle){access(u);return db.readTransaction(()->{ + var s=state(u,job,vehicle,false);var j=s.order().job();var next=allocation(s); + return fields("revision",revision(s),"vehicle",vehicleView(s.vehicle()),"previousSid",text(j,"cr_sid"),"previousCourierId",id(j,"cr_id"),"previousHeadquartersId",id(j,"hq_id_exec"),"previousCourierNet",decimal(j,"jb_cr_price"),"courierNet",courierNet(s),"executionAmount",next.amount(),"finishTime",text(j,"jb_finishtime"),"bookingTime",text(j,"jb_booktime")); + });} + @PostMapping Map save(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);} + Map save(LegacyUser u,long job,Command c){ + access(u);if(c==null||!c.confirmed()||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Bitte den geprüften Fahrzeugwechsel bestätigen.");BusinessSupport.token(c.requestId()); + support.transactional("headquarters","job","tour","tourservice","jobprice","jobpayment","genericdatacontainer","courier","couriervehicle"); + db.writeTransaction(()->{ + lockHeadquarters(db,u);var intent=List.of("COMPLETED_VEHICLE",job,c);if(support.receipt(u,c.requestId(),intent)!=null)return null; + var s=state(u,job,c.vehicleId(),true);unchanged(s,c.revision());var v=s.vehicle();var j=s.order().job();var accounting=allocation(s); + if(id(j,"cr_id")==id(v,"cr_id")&&text(j,"cr_sid").equals(text(v,"crvh_sid"))&&id(j,"vht_id_real")==id(v,"vht_id"))throw bad("Dieses Fahrzeug ist bereits als Ausführungsfahrzeug hinterlegt."); + db.update("UPDATE job SET cr_id=?,cr_sid=?,vht_id_real=?,hq_id_exec=?,jb_longhaul_export=?,jb_hq_id_exec_price=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=? AND hq_id=?",id(v,"cr_id"),text(v,"crvh_sid"),id(v,"vht_id"),accounting.headquarters(),accounting.export(),accounting.amount(),job,u.headquartersId()); + OrderCourierAgio.apply(db,job,s.agio()); + // Old emissions and partner commission belong to the previous vehicle. Keep measured route distance. + db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort IN (12,14)",job); + orders.markInvoiceDirty(job);OrderCompletionProcessing.enqueue(db,job);orders.refreshCompletion(u,job,id(j,"jb_incomplete")==1); + support.audit(u,job,0,id(v,"cr_id"),21,"Fahrzeug geändert: "+text(j,"cr_sid")+" → "+text(v,"crvh_sid")+"; Kurier "+id(j,"cr_id")+" → "+id(v,"cr_id")+"; Frachtvergütung "+decimal(j,"jb_cr_price")+" → "+courierNet(s),c.requestId()); + support.receipt(u,c.requestId(),intent,job);return null; + });return orders.view(u,job); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java new file mode 100644 index 0000000..c46c5eb --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletionChecks.java @@ -0,0 +1,59 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.math.BigDecimal; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.bad; + +/** Current PHP check_jb_incomplete rules for central edits and final completion. */ +final class OrderCompletionChecks { + static List reasons(Map job,Map customer,List> stops, + List> prices,List> details,boolean customerAccount, + boolean stationRequired,boolean everyStation,boolean sustainabilityRequired) { + var reasons=new ArrayList();BigDecimal sum=BigDecimal.ZERO;boolean missing=false; + for(var price:prices){ + if(text(price,"trs_srvt_name").equals("z")||text(price,"trs_srv_name").contains("Grundpreis"))continue; + var amount=LegacyOrderPricing.nz(decimal(price,"trs_price")); + if(amount.abs().compareTo(new BigDecimal("0.02"))<0){missing=true;break;} + sum=sum.add(amount.multiply(BigDecimal.ONE.subtract(LegacyOrderPricing.nz(decimal(price,"trs_discount")).movePointLeft(2)))); + } + if(missing||sum.signum()==0)reasons.add("Ein abrechenbarer Fahrpreis fehlt oder eine Preisposition liegt unter 0,02 EUR."); + if(!customerAccount){ + if(id(customer,"cs_jb_commission_no")==1&&text(job,"jb_commission_no").isBlank())reasons.add("Die erforderliche Auftragskommissionsnummer fehlt."); + if(id(customer,"cs_commission_no")==1&&stationRequired&&(everyStation?stops.stream().anyMatch(t->text(t,"tr_commission_no").isBlank()):stops.stream().allMatch(t->text(t,"tr_commission_no").isBlank()))) + reasons.add(everyStation?"Die erforderliche Kommissionsnummer fehlt an mindestens einer Station.":"Eine Kommissionsnummer an mindestens einer Station ist erforderlich."); + } + if(id(job,"jb_status")==2){ + var emissions=details.stream().filter(p->id(p,"mt_sort")==12).toList(); + if(sustainabilityRequired&&(emissions.size()!=1||LegacyOrderPricing.nz(decimal(emissions.getFirst(),"jbprc_price")).signum()<=0))reasons.add("Für den Nachhaltigkeitsbericht fehlt ein eindeutiger positiver CO₂e-Wert."); + if(id(job,"jb_storno")==0&&id(job,"cr_id")<=0)reasons.add("Der ausführende Kurier fehlt."); + } + return List.copyOf(reasons); + } + static List reasons(DatabaseService db,OrderEntryCatalog catalog,LegacyUser user,OrderOperations.Snapshot snapshot) { + var job=snapshot.job();var customers=db.queryForList("SELECT cs.cs_id,cs.cs_jb_commission_no,cs.cs_commission_no,cs.cs_id_parent,cmp.cmp_sustainability_email FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id=?",id(job,"csc_id_payer")); + if(customers.size()>1)throw bad("Der Auftragszahler ist nicht eindeutig zugeordnet."); + var cs=customers.isEmpty()?Map.of():customers.getFirst();boolean sustainability=!text(cs,"cmp_sustainability_email").isBlank(); + if(!sustainability&&id(cs,"cs_id_parent")>0){var parents=db.queryForList("SELECT cmp.cmp_sustainability_email FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.cs_id=?",id(cs,"cs_id_parent"));sustainability=parents.stream().anyMatch(p->!text(p,"cmp_sustainability_email").isBlank());} + long customer=id(cs,"cs_id"); + boolean customerStationRequired=catalog.enabled(0,"CS_MASK_COMM_NO_B_"+customer); + boolean bookingStationRequired=!user.customerAccount()&&!catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")&&catalog.enabled(user.headquartersId(),"MASK_JOBDETAILS_BOOKJOB_COMMNO"); + return reasons(job,cs,snapshot.stops(),snapshot.prices(),snapshot.priceDetails(),user.customerAccount(),customerStationRequired||bookingStationRequired,customerStationRequired&&catalog.enabled(0,"CS_MASK_COMM_NO_C_"+customer),sustainability); + } + static void refresh(OrderOperations orders,LegacyUser user,long id,boolean keepIncomplete) { + var snapshot=orders.snapshot(user,id,true); + if(OrderOperations.exported(snapshot.job())||!snapshot.invoices().isEmpty())throw bad("Die Abschlussprüfung darf exportierte oder abgerechnete Aufträge nicht ändern."); + int incomplete=keepIncomplete||!reasons(orders.db,orders.catalog,user,snapshot).isEmpty()?1:0; + orders.db.update("UPDATE job SET jb_incomplete=? WHERE jb_id=? AND hq_id=?",incomplete,id,user.headquartersId()); + // Native/PHP subcontractor documents consist of direct children and their counter-documents. + var children=orders.db.lockRows("SELECT * FROM job WHERE jb_id_parent=? ORDER BY jb_id",id); + var linked=new ArrayList<>(children);for(var child:children)linked.addAll(orders.db.lockRows("SELECT * FROM job WHERE jb_id_parent=? ORDER BY jb_id",id(child,"jb_id"))); + var seen=new HashSet();seen.add(id); + for(var child:linked){long childId=id(child,"jb_id");if(!seen.add(childId)||id(child,"hq_id")!=user.headquartersId())throw bad("Die verbundenen Unternehmerbelege sind nicht eindeutig dieser Niederlassung zugeordnet."); + if(id(child,"jb_incomplete")==incomplete)continue; + if(OrderOperations.exported(child)||!orders.db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",childId).isEmpty())throw bad("Ein verbundener Unternehmerbeleg ist bereits abgerechnet."); + orders.db.update("UPDATE job SET jb_incomplete=?,jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",incomplete,childId); + } + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletionProblems.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletionProblems.java new file mode 100644 index 0000000..2ad1267 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletionProblems.java @@ -0,0 +1,26 @@ +package de.stadtbote.votian; + +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.revision; + +/** Only these verified business failures may generate an operational alert. */ +final class OrderCompletionProblems { + enum Kind { COURIER, ADDRESS } + record Problem(Kind kind,int station,String basis) { + String key(){return "COMPLETION_"+kind.name()+"_"+basis.substring(0,16);} + } + static String basis(Map job,List> stops){ + return revision(List.of(id(job,"jb_id"),id(job,"hq_id"),id(job,"jb_version"),id(job,"cr_id"),id(job,"csc_id_payer"),OrderRouteCache.stationRevision(stops))); + } + static boolean eligible(Map job){return Set.of(1L,2L).contains(id(job,"jb_status"))&&id(job,"jb_storno")==0&&id(job,"jb_id_parent")<=0&&!OrderOperations.exported(job);} + static Problem capture(OrderOperations.Snapshot current,RuntimeException failure){ + if(!eligible(current.job())||!current.invoices().isEmpty())return null; + String basis=basis(current.job(),current.stops()); + if(id(current.job(),"jb_status")==2&&id(current.job(),"cr_id")<=0)return new Problem(Kind.COURIER,0,basis); + if(failure instanceof OrderRoutingService.StationAddressException e&&e.addresses.equals(OrderRoutingService.addressRevision(stations(current.stops())))) + return new Problem(Kind.ADDRESS,e.station,basis); + return null; + } + static List stations(List> stops){return 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();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java new file mode 100644 index 0000000..43fc3a5 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletionProcessing.java @@ -0,0 +1,87 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import java.math.*;import java.time.*;import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.OrderEntryService.fields; + +/** Native replacement for check_cr_agio/get_job_distance; disabled while PHP still owns the queue. */ +@RestController +final class OrderCompletionProcessing { + static final String PENDING="ng_completion_pending",RESULT="ng_completion_result"; + record State(OrderCompletionSustainability.State sustainability,OrderInvoicePreparation.Prepared invoice, + OrderCourierAgio.Source agio,List> queue,List> pending,List routes) {} + record Run(LocalDateTime at,int checked,int processed,int failed) {} + final DatabaseService db;final BusinessSupport support;final OrderOperations orders;final OrderCompletionSustainability sustainability;final OrderInvoicePreparation invoice;final OrderRouteCache cache; + final boolean enabled,phpStopped;final Set headquarters;final String queue;long cursor;volatile Run lastRun=new Run(null,0,0,0); + OrderCompletionProcessing(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderCompletionSustainability sustainability,OrderInvoicePreparation invoice, + @Value("${app.completion.enabled:false}") boolean enabled,@Value("${app.completion.php-stopped:false}") boolean phpStopped,@Value("${app.completion.headquarters:}") String allowed){ + this.db=db;this.support=support;this.orders=orders;this.sustainability=sustainability;this.invoice=invoice;this.enabled=enabled;this.phpStopped=phpStopped;this.headquarters=parseHeadquarters(allowed);queue=support.historySchema+".b2b_objects";cache=new OrderRouteCache(db,support); + } + static Set parseHeadquarters(String raw){var result=new TreeSet();if(raw!=null&&!raw.isBlank())for(String part:raw.split(",",-1))try{long id=Long.parseLong(part.strip());if(id<=0)throw new IllegalArgumentException();result.add(id);}catch(RuntimeException e){throw new IllegalArgumentException("Abschlussverarbeitung: ungültige Niederlassungsfreigabe.");}return Set.copyOf(result);} + boolean active(long hq){return enabled&&phpStopped&&headquarters.contains(hq);} + static LegacyUser actor(long hq){return new LegacyUser(0,0,hq,"Auftragsabschluss","1".repeat(64));} + static void enqueue(DatabaseService db,long job){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,PENDING);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname",PENDING,"gdc_content",UUID.randomUUID().toString(),"gdc_context","PENDING"));} + List> pending(long job,boolean lock){String sql="SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=? ORDER BY gdc_id";return lock?db.lockRows(sql,job,PENDING):db.queryForList(sql,job,PENDING);} + List> queue(long job,boolean lock){String sql="SELECT bo_id,bo_state FROM "+queue+" WHERE bo_type=220 AND bo_state>0 AND bo_ext_id0=? ORDER BY bo_id";return lock?db.lockRows(sql,job):db.queryForList(sql,job);} + State state(LegacyUser user,long job,boolean lock){ + if(!db.queryForList("SELECT sp_id FROM "+support.historySchema+".semaphor WHERE sp_obj_type='jb' AND sp_obj_id=? AND sp_fieldname='get_job_distance' LIMIT 1",job).isEmpty())throw bad("Die PHP-Abschlussverarbeitung arbeitet noch an diesem Auftrag."); + return new State(sustainability.processingState(user,job,lock),invoice.prepare(user,job,lock),OrderCourierAgio.read(db,job,lock),queue(job,lock),pending(job,lock),cache.targets(job)); + } + void transactional(){orders.transactional();support.transactional("jobcalculator",support.historySchema.replace("`","")+".b2b_objects");} + // A recalled, cancelled or invoiced job no longer needs the queued derivation. + // Clear only the captured queue entries; never change its business data. + boolean settleObsolete(LegacyUser user,long job){return db.writeTransaction(()->{ + lockHeadquarters(db,user);var queued=queue(job,true);var pending=pending(job,true);if(queued.isEmpty()&&pending.isEmpty())return false; + var current=orders.snapshot(user,job,true);var j=current.job(); + if(Set.of(1L,2L).contains(id(j,"jb_status"))&&id(j,"jb_storno")==0&&!OrderOperations.exported(j)&¤t.invoices().isEmpty())return false; + for(var row:queued)db.update("UPDATE "+queue+" SET bo_state=0 WHERE bo_id=? AND bo_type=220 AND bo_ext_id0=? AND bo_state=?",id(row,"bo_id"),job,id(row,"bo_state")); + for(var row:pending)db.update("DELETE FROM genericdatacontainer WHERE gdc_id=?",id(row,"gdc_id")); + record(job,"SUPERSEDED","Die Vormerkung ist durch den aktuellen Auftrags- oder Abrechnungsstatus überholt.");return true; + });} + boolean process(long hq,long job){ + if(!active(hq))return false;var user=actor(hq);transactional();if(settleObsolete(user,job))return true; + var before=db.readTransaction(()->{if(queue(job,false).isEmpty()&&pending(job,false).isEmpty())return null;return state(user,job,false);});if(before==null)return false; + if(!sustainability.sustainability.configured())throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst ist für die Abschlussverarbeitung nicht konfiguriert."); + if(before.sustainability().vehicle().isEmpty())throw bad("Für die Abschlussverarbeitung fehlt das Ausführungsfahrzeug."); + var stops=OrderCompletionSustainability.stations(before.sustainability());var calc=before.invoice().result().calculatorKilometers();String marker=OrderCompletionSustainability.marker(before.sustainability()); + boolean keep=OrderCompletionSustainability.keepDistance(marker)&&!(calc==null&&marker.equals("c")); + 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()); + 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)); + OrderCourierAgio.refresh(db,job); + if(calc!=null||!keep)sustainability.replace(job,11,result.distanceKm(),calc!=null?"c":"ng"); + sustainability.replace(job,12,result.co2eKg(),result.calculation());sustainability.replace(job,14,OrderCompletionSustainability.commission(current.sustainability()),"crvh_partner_commission"); + OrderExecutionAccounting.refreshProcessing(db,invoice.catalog,job); + // Refresh after writing final amounts. The invoice routine owns the single version increment. + invoice.write(user,job,invoice.prepare(user,job,true));orders.refreshCompletion(user,job,id(current.sustainability().order().job(),"jb_incomplete")==1); + cache.enqueue(job,routePlan); + for(var row:current.queue())db.update("UPDATE "+queue+" SET bo_state=0 WHERE bo_id=? AND bo_type=220 AND bo_ext_id0=? AND bo_state=?",id(row,"bo_id"),job,id(row,"bo_state")); + for(var row:current.pending())db.update("DELETE FROM genericdatacontainer WHERE gdc_id=?",id(row,"gdc_id")); + record(job,"DONE","Sonderprovision, Kilometer, CO₂e und Rechnungsausgabe aufbereitet.");support.audit(user,job,id(current.invoice().customer(),"cs_id"),id(current.sustainability().order().job(),"cr_id"),165,"Automatische Abschlussaufbereitung",token);return true; + }); + } + void record(long job,String state,String message){record(job,state,message,null);} + void record(long job,String state,String message,OrderCompletionProblems.Problem problem){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",job,RESULT);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname",RESULT,"gdc_content",support.encode(fields("at",LocalDateTime.now().withNano(0).toString(),"message",message,"problem",problem)),"gdc_context",state));} + @GetMapping("/api/orders/{id}/completion-processing") Object get(Authentication auth,@PathVariable long id){return view(ApiController.user(auth),id);} + Map view(LegacyUser user,long job){user.require(4);return db.readTransaction(()->{orders.snapshot(user,job,false);var rows=db.queryForList("SELECT gdc_gen_fieldname,gdc_context,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN (?,?) ORDER BY gdc_id",job,RESULT,OrderRouteCache.KEY);String state="NONE",message="Noch keine automatische Aufbereitung ausgeführt.",at="",route="NONE";for(var r:rows)if(text(r,"gdc_gen_fieldname").equals(RESULT)){state=text(r,"gdc_context");try{var n=support.json.readTree(text(r,"gdc_content"));message=n.path("message").asText();at=n.path("at").asText();}catch(Exception e){state="FAILED";message="Der Verarbeitungsbeleg ist unlesbar.";}}else route=text(r,"gdc_context");return fields("enabled",active(user.headquartersId()),"state",state,"message",message,"at",at,"routeCache",route,"pending",!pending(job,false).isEmpty()||!queue(job,false).isEmpty(),"lastRun",lastRun);});} + @Scheduled(initialDelay=60000,fixedDelay=30000) + public synchronized void run(){ + if(!enabled||!phpStopped||headquarters.isEmpty())return;int checked=0,processed=0,failed=0; + try{ + var args=new ArrayList(headquarters);args.add(cursor); + var jobs=db.queryForList("SELECT DISTINCT j.jb_id,j.hq_id FROM job j WHERE j.hq_id IN ("+CrmScope.marks(headquarters.size())+") AND j.jb_id>? AND (EXISTS(SELECT 1 FROM "+queue+" b WHERE b.bo_ext_id0=j.jb_id AND b.bo_type=220 AND b.bo_state>0) OR EXISTS(SELECT 1 FROM genericdatacontainer g WHERE g.gdc_obj_type='jb' AND g.gdc_obj_id=j.jb_id AND (g.gdc_gen_fieldname='ng_completion_pending' OR (g.gdc_gen_fieldname='ng_completion_route_cache' AND g.gdc_context='PENDING')))) ORDER BY j.jb_id LIMIT 20",args.toArray()); + if(jobs.isEmpty())cursor=0; + for(var row:jobs){long job=id(row,"jb_id"),hq=id(row,"hq_id");cursor=job;checked++;try{if(process(hq,job))processed++;if(cache.flush(actor(hq),job,orders))db.writeTransaction(()->{db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=? AND gdc_context='DONE_CACHE_PENDING'",job,RESULT);return null;});}catch(RuntimeException e){failed++;String message=e instanceof org.springframework.web.server.ResponseStatusException r?r.getReason():"Die Abschlussaufbereitung ist fehlgeschlagen. Auftrag und Schnittstellen prüfen.";try{db.writeTransaction(()->{lockHeadquarters(db,actor(hq));var locked=db.lockRows("SELECT * FROM job WHERE jb_id=? AND hq_id=?",job,hq);if(!locked.isEmpty()){boolean stillPending=!queue(job,true).isEmpty()||!pending(job,true).isEmpty();if(stillPending&&id(locked.getFirst(),"jb_status")==2&&id(locked.getFirst(),"jb_storno")==0&&!OrderOperations.exported(locked.getFirst()))orders.refreshCompletion(actor(hq),job,true);record(job,stillPending?"FAILED":"DONE_CACHE_PENDING",message,stillPending?OrderCompletionProblems.capture(orders.snapshot(actor(hq),job,true),e):null);}return null;});}catch(RuntimeException ignored){}}} + }catch(RuntimeException e){failed++;}lastRun=new Run(LocalDateTime.now().withNano(0),checked,processed,failed); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCompletionSustainability.java b/backend/src/main/java/de/stadtbote/votian/OrderCompletionSustainability.java new file mode 100644 index 0000000..21af8cc --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCompletionSustainability.java @@ -0,0 +1,91 @@ +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 static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.*; +import static de.stadtbote.votian.OrderEntryService.fields; +import static de.stadtbote.votian.OrderEntryModels.*; + +/** Kilometer/CO2 follow-up for completed jobs; preview calls integrations outside transactions. */ +@RestController +@RequestMapping("/api/orders/{id}/sustainability") +final class OrderCompletionSustainability { + record Input(SustainabilityMode mode,BigDecimal distanceKm,BigDecimal co2eKg,String reason){} + record Command(String requestId,String revision,Input input,String fingerprint){} + record State(OrderOperations.Snapshot order,Map vehicle,List> commission,List> factors){} + record Preview(State state,Input input,SustainabilityResult result,String distanceMarker,String fingerprint,Instant expires){} + final DatabaseService db;final BusinessSupport support;final OrderOperations orders;final OrderSustainability sustainability;final OrderRoutingService routing;final Clock clock; + final Map previews=new java.util.concurrent.ConcurrentHashMap<>(); + @org.springframework.beans.factory.annotation.Autowired + OrderCompletionSustainability(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderSustainability sustainability,OrderRoutingService routing){this(db,support,orders,sustainability,routing,Clock.systemUTC());} + OrderCompletionSustainability(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderSustainability sustainability,OrderRoutingService routing,Clock clock){this.db=db;this.support=support;this.orders=orders;this.sustainability=sustainability;this.routing=routing;this.clock=clock;} + @GetMapping Object get(Authentication a,@PathVariable long id){return options(ApiController.user(a),id);} + @PostMapping Object post(Authentication a,@PathVariable long id,@RequestBody Command c){return preview(ApiController.user(a),id,c);} + @PutMapping Object put(Authentication a,@PathVariable long id,@RequestBody Command c){return save(ApiController.user(a),id,c);} + static void authorize(LegacyUser user){user.require(4);user.require(7);} + State state(LegacyUser user,long id,boolean lock){return state(user,id,lock,false);} + State processingState(LegacyUser user,long id,boolean lock){return state(user,id,lock,true);} + private State state(LegacyUser user,long id,boolean lock,boolean accepted){ + var order=orders.snapshot(user,id,lock);orders.writable(order);var job=order.job(); + if((id(job,"jb_status")!=2&&(!accepted||id(job,"jb_status")!=1))||id(job,"jb_id_parent")>0)throw bad(accepted?"Die Aufbereitung benötigt einen angenommenen oder erledigten Hauptauftrag.":"Nachhaltigkeitsdaten werden am erledigten Hauptauftrag bearbeitet."); + var vehicles=db.queryForList("SELECT crvh_id,cr_id,crvh_sid,vht_id,crvh_payload,crvh_env_badge_level FROM couriervehicle WHERE cr_id=? AND crvh_sid=? ORDER BY crvh_id",id(job,"cr_id"),text(job,"cr_sid")); + if(vehicles.size()>1)throw bad("Das Ausführungsfahrzeug ist nicht eindeutig zugeordnet."); + var vehicle=vehicles.isEmpty()?Map.of():vehicles.getFirst(); + var commission=vehicle.isEmpty()||dateTime(job,"jb_finishtime")==null?List.>of():db.queryForList("SELECT srvprch_price,srvprch_validfrom FROM servicepricehistory WHERE srvprch_obj_type='crvh' AND srvprch_obj_id=? AND srvprch_fieldname='crvh_partner_commission' AND srvprch_validfrom<=? ORDER BY srvprch_validfrom DESC LIMIT 2",id(vehicle,"crvh_id"),dateTime(job,"jb_finishtime")); + if(commission.size()>1&&Objects.equals(commission.getFirst().get("srvprch_validfrom"),commission.getLast().get("srvprch_validfrom")))throw bad("Die Partnerprovision ist am Abschlussdatum mehrdeutig."); + var factors=db.queryForList("SELECT hq_id,par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id IN (0,?) AND par_key IN ('CO2_FORMULAR_L','CO2_FORMULAR_U','CO2_FORMULAR_TF') ORDER BY hq_id,par_key,par_id",user.headquartersId()); + for(int type:List.of(11,12,14))if(order.priceDetails().stream().filter(p->id(p,"mt_sort")==type).count()>1)throw bad("Mehrere Nachhaltigkeitspositionen gleichen Typs. Bitte die Bestandsdaten prüfen."); + return new State(order,vehicle,commission,factors); + } + static BigDecimal stored(State state,int type){var rows=state.order().priceDetails().stream().filter(p->id(p,"mt_sort")==type).toList();return rows.isEmpty()?null:decimal(rows.getFirst(),"jbprc_price");} + static String marker(State state){return state.order().priceDetails().stream().filter(p->id(p,"mt_sort")==11).map(p->text(p,"jbprc_remark")).findFirst().orElse("");} + static boolean keepDistance(String marker){return marker.strip().equalsIgnoreCase("m")||marker.equals("c");} + static BigDecimal money(BigDecimal value){return value==null?null:value.setScale(2,RoundingMode.HALF_UP);} + static BigDecimal commission(State state){var value=state.commission().isEmpty()?BigDecimal.ZERO:decimal(state.commission().getFirst(),"srvprch_price");if(value==null)throw bad("Die Partnerprovision enthält keinen gültigen Betrag.");return value;} + Map options(LegacyUser user,long id){authorize(user);return db.readTransaction(()->{var s=state(user,id,false);return fields("revision",revision(s),"configured",sustainability.configured(),"vehicle",text(s.vehicle(),"crvh_sid"),"distanceKm",stored(s,11),"co2eKg",stored(s,12),"keepDistance",keepDistance(marker(s)),"partnerCommission",commission(s));});} + static void validate(Command c){ + if(c==null||c.input()==null||c.input().mode()==null)throw bad("Bitte die Nachhaltigkeitsberechnung auswählen.");BusinessSupport.token(c.requestId());var input=c.input(); + if(input.reason()!=null&&input.reason().length()>200)throw bad("Die Begründung darf höchstens 200 Zeichen enthalten."); + if(input.mode()==SustainabilityMode.AUTO){if(input.distanceKm()!=null||input.co2eKg()!=null)throw bad("Automatische Berechnung und manuelle Werte dürfen nicht kombiniert werden.");} + else {if(input.reason()==null||input.reason().isBlank())throw bad("Bitte die manuelle Korrektur begründen.");validateValue(input.distanceKm(),3,"Kilometer");validateValue(input.co2eKg(),2,"CO₂e");} + } + static void validateValue(BigDecimal value,int scale,String name){if(value==null||value.signum()<=0||value.compareTo(new BigDecimal("999999.99"))>0||value.scale()>scale)throw bad(name+" muss positiv sein und darf höchstens "+scale+" Nachkommastellen haben.");} + String key(LegacyUser user,long id,String token){return user.userId()+"|"+user.headquartersId()+"|"+id+"|"+token;} + Map preview(LegacyUser user,long id,Command c){ + authorize(user);validate(c);var before=db.readTransaction(()->{var s=state(user,id,false);unchanged(s,c.revision());return s;}); + SustainabilityResult value;String distanceMarker; + if(c.input().mode()==SustainabilityMode.MANUAL){distanceMarker="m";value=new SustainabilityResult(c.input().distanceKm(),c.input().co2eKg(),"Manuell","m",revision(c.input()));} + else { + if(!sustainability.configured())throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst ist noch nicht konfiguriert (CO2_URL, CO2_USERNAME, CO2_PASSWORD)."); + if(before.vehicle().isEmpty())throw bad("Für die automatische Berechnung fehlt das Ausführungsfahrzeug."); + BigDecimal km=keepDistance(marker(before))?stored(before,11):routing.completedDistance(user,stations(before)); + distanceMarker=keepDistance(marker(before))?marker(before):"ng"; + var input=sustainability.completedInput(user,id,c.requestId(),before.vehicle(),km,decimal(before.order().job(),"jb_weight"));value=sustainability.resolveBatch(input); + } + return db.readTransaction(()->{var current=state(user,id,false);unchanged(current,c.revision());String fingerprint=revision(List.of(c.revision(),c.input(),value,distanceMarker)); + previews.entrySet().removeIf(e->!e.getValue().expires().isAfter(clock.instant()));if(previews.size()>=500)previews.clear();previews.put(key(user,id,c.requestId()),new Preview(current,c.input(),value,distanceMarker,fingerprint,clock.instant().plusSeconds(300))); + return fields("fingerprint",fingerprint,"distanceKm",value.distanceKm(),"co2eKg",value.co2eKg(),"source",value.source(),"distancePreserved",c.input().mode()==SustainabilityMode.AUTO&&keepDistance(marker(current)),"partnerCommission",commission(current),"previousDistanceKm",stored(current,11),"previousCo2eKg",stored(current,12));}); + } + static List stations(State s){return s.order().stops().stream().map(t->new 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();} + Object intent(long id,Command c){return List.of("ORDER_SUSTAINABILITY",id,c);} + Map save(LegacyUser user,long id,Command c){ + authorize(user);validate(c);if(db.readTransaction(()->support.receipt(user,c.requestId(),intent(id,c)))!=null)return orders.view(user,id); + var preview=previews.get(key(user,id,c.requestId()));if(preview==null||!preview.expires().isAfter(clock.instant())||!preview.fingerprint().equals(c.fingerprint())||!preview.input().equals(c.input()))throw bad("Die Nachhaltigkeitsvorschau ist abgelaufen oder wurde geändert. Bitte neu berechnen."); + unchanged(preview.state(),c.revision()); + orders.transactional();db.writeTransaction(()->{lockHeadquarters(db,user);if(support.receipt(user,c.requestId(),intent(id,c))!=null)return null; + var current=state(user,id,true);unchanged(current,c.revision());var value=preview.result(); + if(c.input().mode()!=SustainabilityMode.AUTO||!keepDistance(marker(current)))replace(id,11,value.distanceKm(),preview.distanceMarker());replace(id,12,value.co2eKg(),value.calculation());replace(id,14,commission(current),"crvh_partner_commission"); + db.update("UPDATE job SET jb_version=COALESCE(jb_version,0)+1 WHERE jb_id=?",id); + orders.refreshCompletion(user,id,id(current.order().job(),"jb_incomplete")==1); + db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_invtext_dirty'",id); + db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context","SUSTAINABILITY")); + support.audit(user,id,0,id(current.order().job(),"cr_id"),165,"Nachhaltigkeitsdaten · "+value.source()+" · "+value.distanceKm()+" km · "+value.co2eKg()+" kg CO2e"+(c.input().reason()==null?"":" · "+c.input().reason()),c.requestId());support.receipt(user,c.requestId(),intent(id,c),id);return null;}); + return orders.view(user,id); + } + void replace(long id,int type,BigDecimal value,String remark){db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort=?",id,type);db.update("INSERT INTO jobprice(jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES(?,?,?,?)",id,type,value,remark.substring(0,Math.min(50,remark.length())));} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCourierAgio.java b/backend/src/main/java/de/stadtbote/votian/OrderCourierAgio.java new file mode 100644 index 0000000..37da53e --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCourierAgio.java @@ -0,0 +1,61 @@ +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; + +/** Historical customer/courier provision adjustment from active global.inc.php:check_cr_agio. */ +final class OrderCourierAgio { + record Source(Map job,List> prices,List> history) {} + record Result(boolean recalculate,BigDecimal agio,BigDecimal subtotal,BigDecimal total) {} + static BigDecimal zero(BigDecimal value){return value==null?BigDecimal.ZERO:value;} + static BigDecimal amount(Map row,String key){return zero(decimal(row,key));} + static BigDecimal price(List> rows,int... types){ + var selected=rows.stream().filter(p->Arrays.stream(types).anyMatch(t->id(p,"mt_sort")==t)).toList(); + if(selected.size()>1)throw bad("Die Preispositionen für die Kurier-Sonderprovision sind mehrdeutig."); + return selected.isEmpty()?BigDecimal.ZERO:amount(selected.getFirst(),"jbprc_price"); + } + static BigDecimal percent(BigDecimal value,BigDecimal deduction){return value.multiply(BigDecimal.valueOf(100).subtract(deduction)).divide(BigDecimal.valueOf(100),2,RoundingMode.HALF_UP);} + static Result calculate(Source s){ + var old=price(s.prices(),7);var agio=s.history().isEmpty()?BigDecimal.ZERO:amount(s.history().getFirst(),"srvprch_price");var deduction=price(s.prices(),5); + if((old.signum()<=0&&agio.signum()<=0)||deduction.signum()<=0)return new Result(false,BigDecimal.ZERO,price(s.prices(),10),amount(s.job(),"jb_cr_price")); + var markup=price(s.prices(),1,9);var fixedService=price(s.prices(),3);var variableService=price(s.prices(),4); + var subtotal=percent(amount(s.job(),"jb_subtotalprice").subtract(amount(s.job(),"jb_serviceprice")),deduction.subtract(agio)); + // The active PHP branch uses the stored service amount in this expression, + // gated by position 6. Preserve that contract rather than substituting its rate. + if(price(s.prices(),6).signum()>0)variableService=percent(variableService,variableService.subtract(agio)); + return new Result(true,agio,subtotal.add(variableService).add(fixedService),percent(subtotal,markup.negate()).add(variableService).add(fixedService)); + } + static Source read(DatabaseService db,long jobId,boolean lock){return read(db,jobId,lock,null);} + /** Historical corrections use the selected vehicle owner, independent of today's login. */ + static Source read(DatabaseService db,long jobId,boolean lock,Long targetCourier){ + String sql="SELECT jb_id,jb_status,jb_storno,jb_export_time,cr_id,csc_id_payer,jb_ordertime,jb_subtotalprice,jb_serviceprice,jb_cr_price FROM job WHERE jb_id=?"; + var rows=lock?db.lockRows(sql,jobId):db.queryForList(sql,jobId); + if(rows.size()!=1)throw bad("Der Auftrag für die Sonderprovision fehlt.");var job=new LinkedHashMap<>(rows.getFirst());if(targetCourier!=null)job.put("cr_id",targetCourier); + if(!Set.of(1L,2L).contains(id(job,"jb_status"))||id(job,"jb_storno")!=0||OrderOperations.exported(job)||id(job,"cr_id")<=0)return null; + String prices="SELECT mt_sort,jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort IN (1,3,4,5,6,7,9,10) ORDER BY mt_sort,jbprc_reference"; + var details=lock?db.lockRows(prices,jobId):db.queryForList(prices,jobId); + if(price(details,5).signum()<=0)return new Source(job,details,List.of()); + var payers=db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",id(job,"csc_id_payer"));if(payers.size()>1||payers.isEmpty()&&id(job,"csc_id_payer")>0)throw bad("Der Zahler für die Sonderprovision ist nicht eindeutig."); + var history=db.queryForList("SELECT srvprch_price,srvprch_validfrom FROM servicepricehistory WHERE srvprch_obj_type='cr' AND srvprch_obj_id=? AND srvprch_fieldname='cscr_prov' AND srvprch_context=? AND srvprch_validfrom<=? ORDER BY srvprch_validfrom DESC LIMIT 2",id(job,"cr_id"),payers.isEmpty()?"":Long.toString(id(payers.getFirst(),"cs_id")),dateTime(job,"jb_ordertime")); + if(history.size()>1&&Objects.equals(history.getFirst().get("srvprch_validfrom"),history.getLast().get("srvprch_validfrom")))throw bad("Die Kunden-Kurier-Sonderprovision ist am Auftragsdatum mehrdeutig."); + return new Source(job,details,history); + } + /** Caller owns the booking transaction/version and audit; no remote calls occur here. */ + static boolean refresh(DatabaseService db,long jobId){ + return apply(db,jobId,read(db,jobId,true)); + } + static boolean apply(DatabaseService db,long jobId,Source source){ + if(source==null)return false;var result=calculate(source);var old=price(source.prices(),7); + if(old.compareTo(result.agio())==0&&(!result.recalculate()||price(source.prices(),10).compareTo(result.subtotal())==0&&amount(source.job(),"jb_cr_price").compareTo(result.total())==0))return false; + db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort=7",jobId); + if(result.recalculate()){ + if(result.agio().signum()!=0)db.update("INSERT INTO jobprice(jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES(?,7,?,'')",jobId,result.agio()); + db.update("DELETE FROM jobprice WHERE jb_id=? AND mt_sort=10",jobId);db.update("INSERT INTO jobprice(jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES(?,10,?,'')",jobId,result.subtotal()); + db.update("UPDATE job SET jb_cr_price=? WHERE jb_id=?",result.total(),jobId); + } + return true; + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCourierAssignment.java b/backend/src/main/java/de/stadtbote/votian/OrderCourierAssignment.java new file mode 100644 index 0000000..de6e504 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCourierAssignment.java @@ -0,0 +1,64 @@ +package de.stadtbote.votian; +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; + +/** Direct list assignment shares the map's vehicle and customer eligibility checks. */ +@RestController +@RequestMapping("/api/orders/{job}/courier-assignment") +public class OrderCourierAssignment { + final DatabaseService db;final OrderOperations orders;final FleetAssignment fleet;final LonghaulRemuneration longhaul; + @org.springframework.beans.factory.annotation.Autowired + public OrderCourierAssignment(DatabaseService db,OrderOperations orders,FleetAssignment fleet,LonghaulRemuneration longhaul){this.db=db;this.orders=orders;this.fleet=fleet;this.longhaul=longhaul;} + OrderCourierAssignment(DatabaseService db,OrderOperations orders,FleetAssignment fleet){this(db,orders,fleet,null);} + public record Input(long vehicleId,LonghaulRemuneration.Start startMode,long courierId){} + public record Command(String requestId,String revision,String fingerprint,long vehicleId,boolean resetPreviousAvailableTime,LonghaulRemuneration.Start startMode,long courierId){ + public Command(String requestId,String revision,String fingerprint,long vehicleId,boolean resetPreviousAvailableTime,LonghaulRemuneration.Start startMode){this(requestId,revision,fingerprint,vehicleId,resetPreviousAvailableTime,startMode,0);} + public Command(String requestId,String revision,String fingerprint,long vehicleId,boolean resetPreviousAvailableTime){this(requestId,revision,fingerprint,vehicleId,resetPreviousAvailableTime,LonghaulRemuneration.Start.CURRENT,0);} + } + record State(FleetAssignment.Prepared assignment,List> previous,boolean related,OrderSurvey.Decision survey){} + static void access(LegacyUser u){u.require(4);u.require(7);} + @GetMapping public Object get(Authentication a,@PathVariable long job){return view(ApiController.user(a),job);} + @GetMapping("/vehicles") public Object vehicles(Authentication a,@PathVariable long job,@RequestParam(defaultValue="") String q){return vehicles(ApiController.user(a),job,q);} + @PostMapping("/preview") public Object preview(Authentication a,@PathVariable long job,@RequestBody Input r){if(r==null)throw bad("Bitte ein Fahrzeug wählen.");return preview(ApiController.user(a),job,r.vehicleId(),r.startMode(),r.courierId());} + @PostMapping public Object save(Authentication a,@PathVariable long job,@RequestBody Command r){return save(ApiController.user(a),job,r);} + Map view(LegacyUser u,long job){access(u);return db.readTransaction(()->{var s=orders.snapshot(u,job,false);return fields("id",job,"courier",text(s.job(),"cr_sid"),"customerNet",decimal(s.job(),"jb_totalprice"),"courierNet",decimal(s.job(),"jb_cr_price"));});} + Map vehicles(LegacyUser u,long job,String query){access(u);if(query==null||query.length()>100)throw bad("Suchtext ist zu lang.");return db.readTransaction(()->{ + boolean bwv=id(orders.snapshot(u,job,false).job(),"jb_longhaul")==1;boolean multiple=AssignmentVehicles.multiple(new OrderEntryCatalog(db),u);String term=OrderListFilters.term(query.trim());String where=AssignmentVehicles.from(bwv,multiple)+" AND (cv.crvh_sid LIKE ? ESCAPE '!' OR cv.crvh_vh_sign LIKE ? ESCAPE '!' OR cmp.cmp_comp LIKE ? ESCAPE '!' OR CONCAT_WS(' ',u.usr_firstname,u.usr_name) LIKE ? ESCAPE '!')";Object[] args=bwv?new Object[]{term,term,term,term}:new Object[]{u.headquartersId(),term,term,term,term}; + long total=db.queryForObject("SELECT COUNT(*) "+where,Long.class,args); + var rows=db.queryForList("SELECT "+AssignmentVehicles.IDENTITY+",cv.crvh_id,cv.crvh_sid,cv.crvh_vh_sign,cv.vht_id,cr.cr_sid,cr.cr_available,cr.cr_occupied,cr.cr_locationzipcode,cr.cr_availabletime,cmp.cmp_comp "+where+" ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 200",args); + return fields("longhaul",bwv,"total",total,"limit",200,"items",AssignmentVehicles.identities(rows).stream().map(v->fields("id",id(v,"crvh_id"),"courierId",id(v,"cr_id"),"ownerCourierId",id(v,"owner_courier_id"),"driverName",text(v,"driver_name"),"ambiguous",AssignmentVehicles.ambiguous(v,multiple),"sid",text(v,"crvh_sid"),"plate",text(v,"crvh_vh_sign"),"name",text(v,"cmp_comp"),"vehicleType",id(v,"vht_id"),"online",text(v,"cr_sid").equals(text(v,"crvh_sid")),"available",id(v,"cr_available")==1,"occupied",id(v,"cr_occupied")!=0,"postalCode",text(v,"cr_locationzipcode"),"availableTime",text(v,"cr_availabletime"))).toList()); + });} + State state(LegacyUser u,long job,long vehicle,long courier,OrderOperations.Snapshot s,boolean lock){ + if(vehicle<=0)throw bad("Bitte ein Fahrzeug wählen."); + var p=fleet.prepareVehicle(u,job,vehicle,courier,s,lock);long previous=id(s.job(),"cr_id"); + String sql="SELECT cr_id,hq_id,cr_sid,cr_availabletime FROM courier WHERE cr_id=?"; + var old=previous<=0?List.>of():(lock?db.lockRows(sql,previous):db.queryForList(sql,previous)); + boolean related=OrderListFilters.related(db);if(id(s.job(),"jb_id_parent")>0&&!related)throw bad("Bitte den Hauptauftrag disponieren."); + var assigned=new LinkedHashMap<>(s.job());boolean online=text(p.vehicle(),"cr_sid").equals(text(p.vehicle(),"crvh_sid"));assigned.putAll(fields("jb_status",online?0:1,"cr_id",online?0:p.preview().courier(),"cr_sid",p.preview().sid())); + var survey=new OrderSurvey(db,new OrderEntryCatalog(db)).evaluate(u,assigned);return new State(p,old,related,survey); + } + record Full(State state,LonghaulRemuneration.Context context){} + Full full(LegacyUser u,long job,long vehicle,long courier,LonghaulRemuneration.Start start,OrderOperations.Snapshot s,boolean lock){var state=state(u,job,vehicle,courier,s,lock);if(id(s.job(),"jb_longhaul")==1&&longhaul==null)throw bad("Die BWV-Vermittlung ist nicht verfügbar.");return new Full(state,longhaul==null?null:longhaul.context(u,s,vehicle,start,lock,state.assignment().preview().courier()));} + static String fingerprint(Full full,LonghaulRemuneration.Prepared price){return price==null?revision(full.state()):revision(List.of(full,price));} + Map preview(LegacyUser u,long job,long vehicle){return preview(u,job,vehicle,LonghaulRemuneration.Start.CURRENT);} + Map preview(LegacyUser u,long job,long vehicle,LonghaulRemuneration.Start start){return preview(u,job,vehicle,start,0);} + Map preview(LegacyUser u,long job,long vehicle,LonghaulRemuneration.Start start,long courier){access(u);var before=db.readTransaction(()->full(u,job,vehicle,courier,start,orders.snapshot(u,job,false),false)); + var price=before.context()==null?null:longhaul.prepare(before.context()); + return db.readTransaction(()->{var s=orders.snapshot(u,job,false);var current=full(u,job,vehicle,courier,start,s,false);unchanged(current,revision(before));var state=current.state();var p=state.assignment().preview();var v=state.assignment().vehicle();boolean online=text(v,"cr_sid").equals(text(v,"crvh_sid")); + return fields("job",job,"vehicleId",vehicle,"courierId",p.courier(),"ownerCourierId",id(v,"owner_courier_id"),"driverName",text(v,"driver_name"),"sid",p.sid(),"vehicle",p.vehicle(),"courierName",text(v,"cmp_comp"),"online",online,"nextStatus",online?"Offen / angeboten":"Angenommen (Offline-Disposition)","previousCourier",p.previousCourier(),"canResetPrevious",state.previous().size()==1&&id(state.previous().getFirst(),"hq_id")==u.headquartersId()&&id(state.previous().getFirst(),"cr_id")!=p.courier(),"previousAvailableTime",state.previous().isEmpty()?"":text(state.previous().getFirst(),"cr_availabletime"),"loseRanking",p.loseRanking(),"rankingAfter",state.assignment().rankingAfter(),"surveyEnabled",state.survey().enabled(),"surveyRequested",state.survey().requested(),"surveyTrigger",state.survey().trigger(),"warnings",p.warnings(),"blockers",p.blockers(),"courierNet",price==null?decimal(s.job(),"jb_cr_price"):price.amounts().totalWithService(),"longhaul",price==null?null:longhaul.view(price),"startMode",LonghaulRemuneration.start(start),"revision",p.orderRevision(),"fingerprint",fingerprint(current,price)); + });} + Map save(LegacyUser u,long job,Command c){access(u);if(c==null||c.vehicleId()<=0||c.fingerprint()==null||!c.fingerprint().matches("[a-f0-9]{64}"))throw bad("Bitte zuerst die Zuweisung prüfen."); + var action=new OrderOperations.Action(c.requestId(),c.revision(),"ASSIGN",c.vehicleId(),null,null,"Auftragsliste · "+c.fingerprint()+(LonghaulRemuneration.start(c.startMode())==LonghaulRemuneration.Start.CURRENT?"":" · MODE="+c.startMode()),false,false); + // Resolve remote legs before locking, and skip all remote calls on a receipt replay. + var intent=OrderOperations.assignmentIntent(job,action,c.resetPreviousAvailableTime(),true,c.courierId()); + if(db.readTransaction(()->orders.support.receipt(u,c.requestId(),intent))!=null)return orders.action(u,job,action,null,c.resetPreviousAvailableTime(),true,null,c.courierId()); + var before=db.readTransaction(()->full(u,job,c.vehicleId(),c.courierId(),c.startMode(),orders.snapshot(u,job,false),false)); + var price=before.context()==null?null:longhaul.prepare(before.context()); + return orders.action(u,job,action,s->{var current=full(u,job,c.vehicleId(),c.courierId(),c.startMode(),s,true);unchanged(current,revision(before));if(!fingerprint(current,price).equals(c.fingerprint()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Die BWV- oder Zuweisungsvorschau hat sich geändert. Bitte erneut prüfen.");var p=current.state().assignment().preview();if(!p.blockers().isEmpty())throw bad(String.join(" · ",p.blockers()));return p.loseRanking()?1:0;},c.resetPreviousAvailableTime(),true,price==null?null:(s,changes)->longhaul.apply(price,s,changes),c.courierId()); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCourierParentPolicy.java b/backend/src/main/java/de/stadtbote/votian/OrderCourierParentPolicy.java new file mode 100644 index 0000000..990e627 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCourierParentPolicy.java @@ -0,0 +1,23 @@ +package de.stadtbote.votian; + +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** PHP get_MASK_INVOICE_SIDS_SID/checkCr_id_order, for configured clearing vehicles. */ +record OrderCourierParentPolicy(boolean enabled,String configuredSid,String marker0,String marker1) { + static OrderCourierParentPolicy read(OrderEntryCatalog catalog,long hq) { + if(!catalog.enabled(hq,"MASK_INVOICE_SHOW_SIDS"))return new OrderCourierParentPolicy(false,"","",""); + String sid=catalog.parameter(hq,"MASK_INVOICE_SIDS_SID").trim(); + String zero=catalog.parameter(0,"MASK_INVOICE_SIDS_SID0").trim(); + String one=catalog.parameter(0,"MASK_INVOICE_SIDS_SID1").trim(); + if(sid.isEmpty()||sid.length()>100||zero.isEmpty()||one.isEmpty()||zero.length()>20||one.length()>20||zero.equals(one)||!sid.contains(zero)&&!sid.contains(one)) + throw invalid("Die Vorgaben für das Verrechnungsfahrzeug sind unvollständig oder widersprüchlich."); + return new OrderCourierParentPolicy(true,sid,zero,one); + } + String preferredSid(int vehicleType) { + return enabled&&vehicleType<7&&!configuredSid.contains(marker0)?configuredSid.replaceFirst(java.util.regex.Pattern.quote(marker1),java.util.regex.Matcher.quoteReplacement(marker0)):configuredSid; + } + void require(String sid) { + if(enabled&&!sid.contains(marker0)&&!sid.contains(marker1)) + throw invalid("Für Kurier-Unteraufträge bitte ein Verrechnungsfahrzeug mit Kennung "+marker0+" oder "+marker1+" wählen."); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCourierThreshold.java b/backend/src/main/java/de/stadtbote/votian/OrderCourierThreshold.java new file mode 100644 index 0000000..7ce8cd8 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCourierThreshold.java @@ -0,0 +1,32 @@ +package de.stadtbote.votian; +import java.math.BigDecimal; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.LegacyOrderPricing.*; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** Active checkCrPriceBounds: manual freight, separate from service freight and discounts. */ +final class OrderCourierThreshold { + static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules(); + public record Warning(BigDecimal percent,BigDecimal customerPrice,BigDecimal maximum,BigDecimal courierPrice) {} + static Warning warning(BigDecimal percent,BigDecimal customerBase,BigDecimal customerMarkup,BigDecimal courierBase,BigDecimal courierMarkup){ + if(percent==null||percent.signum()<=0||courierBase==null||courierBase.signum()<=0)return null; + var customer=markup(nz(customerBase),nz(customerMarkup));var courier=markup(courierBase,nz(courierMarkup));var maximum=money(customer.multiply(percent).movePointLeft(2)); + return courier.compareTo(maximum)>0?new Warning(percent,customer,maximum,courier):null; + } + static Quote apply(OrderEntryCatalog catalog,LegacyUser user,Request request,Quote quote){ + if(user.customerAccount()||quote.courierLimitExceeded()||!catalog.enabled(user.headquartersId(),"MASK_CR_PRICE_MODE")||request.pricing().courierPrice()==null||request.pricing().courierPrice().signum()<=0)return quote; + String raw=catalog.parameter(0,"JB_CR_PRICE_THRESHOLD");if(raw.isBlank())return quote;BigDecimal percent; + try{percent=new BigDecimal(raw.replace(',','.'));}catch(NumberFormatException e){throw invalid("Die Warnschwelle der Frachtvergütung ist ungültig.");} + var base=request.pricing().mode()==PriceMode.FIXED?request.pricing().fixedPrice():quote.calculatedBase(); + return attach(quote,warning(percent,base,quote.markup(),request.pricing().courierPrice(),quote.courierMarkup())); + } + static Quote attach(Quote quote,Warning warning){ + if(warning==null)return quote; + ObjectNode tree=JSON.valueToTree(quote);tree.set("courierThreshold",JSON.valueToTree(warning));tree.put("fingerprint",hash(quote.fingerprint()+"|"+warning)); + try{return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);} + } + static void confirm(Quote quote,boolean confirmed){if(quote.courierThreshold()!=null&&!confirmed)throw invalid("Die Warnschwelle der Frachtvergütung ist überschritten. Bitte ausdrücklich bestätigen.");} + static String description(Warning w){return "THRESHOLD="+w.maximum().toPlainString()+"|OVERRIDE="+w.courierPrice().toPlainString()+"|PERCENT="+w.percent().toPlainString();} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java b/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java new file mode 100644 index 0000000..4f4470c --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderCustomerMailContent.java @@ -0,0 +1,161 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; +import java.math.*; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.AdminSupport.bad; +import static de.stadtbote.votian.OrderEntryCatalog.number; + +/** Customer-facing content; creation and manual status mails share the same visibility rules. */ +final class OrderCustomerMailContent { + record Content(String subject,String html,List warnings,String revision,boolean pdf) {} + final DatabaseService db;final long hq;final OrderEntryCatalog catalog;final String legacy; + OrderMailLanguage language=OrderMailLanguage.GERMAN; + String t(String key){return language.text(key);} + String mailText(String key,String fallback,boolean translateConfigured){String value=local(key);return value.isBlank()?t(fallback):translateConfigured?t(value):value;} + final Map parameters=new TreeMap<>(); + OrderCustomerMailContent(DatabaseService db,long hq,String legacy){this.db=db;this.hq=hq;this.catalog=new OrderEntryCatalog(db);this.legacy=Objects.toString(ApiController.safeLegacyUrl(legacy),"");} + String parameter(long headquarters,String key){return parameters.computeIfAbsent(headquarters+":"+key,k->catalog.parameter(headquarters,key));} + String local(String key){return parameter(hq,key);} + String fallback(String key){String value=local(key);return value.isBlank()?parameter(0,key):value;} + String object(String key,long customer){for(var item:List.of(Map.entry(hq,key+"_"+customer),Map.entry(0L,key+"_"+customer),Map.entry(hq,key),Map.entry(0L,key))){String value=parameter(item.getKey(),item.getValue());if(!value.isBlank())return value;}return "";} + String defaulted(String key,String value){String configured=local(key);return configured.isBlank()?value:configured;} + static boolean bit(String flags,int index){return flags.length()>index&&flags.charAt(index)=='1';} + static String escape(Object value){return org.jsoup.nodes.Entities.escape(Objects.toString(value,""));} + static String rich(String value){var doc=Jsoup.parseBodyFragment(value.replace("\n","
"));doc.select("script,style,iframe,object,embed,form,input,button,meta,link,base,img").remove();return Jsoup.clean(doc.body().html(),Safelist.relaxed().removeTags("img").addProtocols("a","href","mailto"));} + String row(String name,Object value){String s=Objects.toString(value,"");return s.isBlank()?"":""+escape(t(name))+""+escape(s).replace("\n","
")+"";} + static String money(BigDecimal value){return String.format(Locale.GERMANY,"%,.2f €",value);} + static String time(Map row,String key){var time=dateTime(row,key);return time==null?"":time.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"));} + String section(String title,String content){return content.isBlank()?"":"

"+escape(t(Set.of("AUFTRAG","TRANSPORTEUR","TOUR","KOSTEN").contains(title)?title+":":title))+"

"+content+"
";} + Content build(Map job,LocalDate date){return build(job,date,null);} + String mode(Map job){ + if(id(job,"jb_offer")!=0||id(job,"jb_storno")!=0)throw bad("Für Angebote und stornierte Aufträge ist diese Auftragsmail nicht vorgesehen."); + return switch((int)id(job,"jb_status")){ + case 2 -> "COMPLETION"; + case 1 -> {if(!local("MASK_MAIL_JOB_STATE_PICKUP").equals("1"))throw bad("Abholungsmails sind für diese Niederlassung nicht freigegeben.");yield "PICKUP";} + case 9 -> {if(!local("MASK_MAIL_JOB_STATE_DISPOSITION").equals("1"))throw bad("Vermittlungsmails sind für diese Niederlassung nicht freigegeben.");yield "DISPOSITION";} + default -> throw bad("Für diesen Auftragsstatus ist keine manuelle Auftragsmail vorgesehen."); + }; + } + Content build(Map job,LocalDate date,OrderDeliveryData evidence){return build(job,date,evidence,date+":"+id(job,"jb_id"));} + Content build(Map job,LocalDate date,OrderDeliveryData evidence,String seed){ + return build(job,date,evidence,seed,null); + } + Content build(Map job,LocalDate date,OrderDeliveryData evidence,String seed,OrderArchive.Snapshot archive){ + return build(job,date,evidence,seed,archive,false); + } + Content build(Map job,LocalDate date,OrderDeliveryData evidence,String seed,OrderArchive.Snapshot archive,boolean changed){ + return buildMode(job,date,evidence,seed,archive,changed,null); + } + /** Explicit automatic event; manual status flags must not disable the PHP background rules. */ + Content automatic(Map job,LocalDate date,OrderDeliveryData evidence,String seed,OrderArchive.Snapshot archive,AutomaticOrderMailPolicy.Event event){ + return buildMode(job,date,evidence,seed,archive,false,event.mode); + } + Content taken(Map job,LocalDate date,OrderDeliveryData evidence,String seed,OrderArchive.Snapshot archive){ + var content=buildMode(job,date,evidence,seed,archive,false,"JOB_TAKEN"); + // automailer9 computes its master subject before jb_detail selects the customer's language. + var subjectLanguage=OrderMailLanguage.load(db,parameter(0,"SYSTEM_LANGUAGE_DEFAULT"),false,"include/mcglobal.inc.php"); + String subject=Jsoup.parse(subjectLanguage.text("Der Transportauftrag")+" "+id(job,"jb_id")+" "+subjectLanguage.text("ist vermittelt!")).text().replaceAll("\\s+"," ").strip(); + return new Content(subject,content.html(),content.warnings(),AdminSupport.revision(List.of(content.revision(),subject,subjectLanguage.revision)),content.pdf()); + } + private Content buildMode(Map job,LocalDate date,OrderDeliveryData evidence,String seed,OrderArchive.Snapshot archive,boolean changed,String automaticMode){ + if(archive!=null)job=archive.job(job); + String mode=automaticMode!=null?automaticMode:changed||evidence==null?"CHANGE":mode(job);boolean completed=mode.equals("COMPLETION"),delivered=id(job,"jb_status")==2; + long order=id(job,"jb_id"),payer=id(job,"csc_id_payer"); + var customers=db.queryForList("SELECT cs.* FROM customer cs JOIN costcenter cc ON cc.cs_id=cs.cs_id WHERE cc.csc_id=?",payer); + if(customers.size()!=1)throw bad("Die Kunden-Mailvorgaben sind nicht eindeutig."); + var customer=customers.getFirst();long customerId=id(customer,"cs_id");String flags=text(customer,"cs_jbstatusmail_fields"); + String configuredLanguage=parameter(0,"JOBDETAILS_EMAIL_LANGUAGE_"+customerId); + boolean archiveLabels=archive!=null&&(!OrderArchiveMail.hasGermanStations(archive.parts().get(2))||db.mailLanguageTables().containsAll(List.of("language","languagetext"))); + language=OrderMailLanguage.load(db,configuredLanguage,archiveLabels); + // Completed/exported snapshots need their historical POD and configured PDF, never a reduced mail. + if(evidence==null&&dateTime(job,"jb_export_time")!=null)throw bad("Exportierte Aufträge bitte über die Auftragsdetails versenden."); + if(archive!=null&&!Long.toString(customerId).equals(archive.parameters().get("csIdPayer")))throw bad("Die archivierte Rechnungskostenstelle ist inzwischen einem anderen Kunden zugeordnet. Bitte die historische Zuordnung prüfen."); + if(evidence==null&&id(job,"jb_status")==2)throw bad("Abgeschlossene Aufträge bitte mit Abliefernachweisen über die Auftragsdetails versenden."); + if(id(job,"jb_offer")!=0&&!mode.equals("JOB_TAKEN")||id(job,"jb_storno")!=0)throw bad("Angebote und stornierte Aufträge erhalten keine bestätigte Erfassungsmail."); + var warnings=new ArrayList(); + var vehicle=new ArrayList>();var courier=new ArrayList>();var stops=new ArrayList>();var costs=new ArrayList>();String toll=""; + StringBuilder data=new StringBuilder("

").append(escape(t("Transportdatum:"))).append(" ").append(escape(parameter(0,"MAIL_JOBDATE").equals("1")?time(job,"jb_ordertime"):date.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")))).append("

"); + if(archive!=null){data.append(new OrderArchiveMail(this,evidence,customerId,flags).render(archive,job,id(customer,"cs_jbstatusmail_price")==1));warnings.add("Historische Auftragsdaten: "+archive.source()+". Preise und Stationsangaben stammen aus dem gespeicherten Abrechnungsstand.");} + else { + if(dateTime(job,"jb_export_time")!=null)warnings.add("Für diesen exportierten Auftrag existiert in keiner erreichbaren Archivtabelle ein Snapshot. Wie in PHP werden die aktuellen Auftragsdaten verwendet; bitte Preise und Nachweise vor dem Versand prüfen."); + vehicle.addAll(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=? ORDER BY mt_id",id(job,"vht_id"))); + if(vehicle.size()!=1)throw bad("Die Fahrzeugart ist nicht eindeutig."); + + data.append(section("AUFTRAG",row("Nr.",order)+row("Re.-Ko.-Nr.",text(job,"jb_commission_no"))+row("Fahrzeug",text(vehicle.getFirst(),"mt_value"))+row("Auftragszeit",time(job,"jb_ordertime"))+row("Annahme",time(job,"jb_taketime"))+row("Erledigung",time(job,"jb_finishtime")))); + if(id(job,"cr_id")>0&&id(job,"jb_status")!=9&&!mode.equals("DISPOSITION")){courier.addAll(db.queryForList("SELECT u.usr_phone,cv.crvh_vh_sign FROM courier cr JOIN user u ON u.usr_id=cr.usr_id LEFT JOIN couriervehicle cv ON cv.cr_id=cr.cr_id AND cv.crvh_sid=? WHERE cr.cr_id=?",text(job,"cr_sid"),id(job,"cr_id"))); + if(courier.size()!=1)throw bad("Der ausführende Transporteur ist nicht eindeutig."); + String plate=bit(flags,6)?row("Kennzeichen",text(courier.getFirst(),"crvh_vh_sign")):""; + if(mode.equals("JOB_TAKEN"))plate=plate.replace("",""); + data.append(section("TRANSPORTEUR",row("SID",text(job,"cr_sid"))+(bit(flags,1)?row("Mobil-Nr.",text(courier.getFirst(),"usr_phone")):"")+plate));} + stops.addAll(db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,cc.csc_name,cs.cs_eid FROM tour t JOIN address a ON a.ad_id=t.ad_id LEFT JOIN costcenter cc ON cc.csc_id=t.csc_id LEFT JOIN customer cs ON cs.cs_id=cc.cs_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",order)); + if(stops.isEmpty()||stops.size()>100)throw bad("Die Auftragsstationen fehlen oder überschreiten den Mailumfang."); + // A newly entered order has no delivery evidence; reject changed/completed stations instead of omitting it. + if(evidence==null&&stops.stream().anyMatch(s->id(s,"tr_status")!=0||!text(s,"tr_sign").isBlank()||!text(s,"tr_signname").isBlank()))throw bad("Der Auftrag enthält bereits Abliefernachweise. Bitte den Versand über die Auftragsdetails verwenden."); + boolean numbers=object("JOBDETAILS_STATIONS_STATION_NUMBER",customerId).equals("1"),hideCenters=object("JOB_MAIL_STATION_COSTCENTER_DISABLED",customerId).equals("1"); + data.append("

").append(escape(t("TOUR:"))).append("

"); + for(var stop:stops){data.append("");if(numbers)data.append(row("Station",id(stop,"tr_sort"))); + data.append(row("Firma",text(stop,"tr_comp"))).append(row("",text(stop,"tr_comp2"))).append(row("Person",text(stop,"tr_person"))) + .append(row("Straße + Hausnr.",text(stop,"ad_street")+" "+text(stop,"tr_hsno"))).append(row("PLZ + Ort",text(stop,"ad_zipcode")+" "+text(stop,"ad_city"))) + .append(row("EID",text(stop,"cs_eid"))); + if(!hideCenters)data.append(row("Kostenstelle",text(stop,"csc_name"))); + data.append(row("Tel.",text(stop,"tr_phone")));if(bit(flags,0))data.append(row("Bemerkung",OrderDeliveryData.publicRemark(text(stop,"tr_remark")))); + data.append(row("Kommissionsnummer",text(stop,"tr_commission_no"))); + if(evidence!=null)data.append(new OrderMailEvidence(this,evidence,customerId,flags).station(order,stop)); + data.append("

");if(data.length()>12_000_000)throw bad("Die Auftragsmail einschließlich Nachweisen überschreitet 12 MB.");} + if(id(customer,"cs_jbstatusmail_price")==1){ + if(!db.queryForList("SELECT ca.csc_id FROM costcenteraddress ca WHERE ca.adt_id=4 AND ca.csc_id IN (SELECT ts.csc_id FROM tourservice ts WHERE ts.jb_id=?) GROUP BY ca.csc_id HAVING COUNT(*)>1",order).isEmpty())throw bad("Die Kostenstellenkontakte für die Preisaufstellung sind mehrdeutig."); + costs.addAll(db.queryForList("SELECT ts.*,cc.csc_name,cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2,u.usr_email,ca.cscad_phone FROM tourservice ts LEFT JOIN costcenter cc ON cc.csc_id=ts.csc_id LEFT JOIN customer cs ON cs.cs_id=cc.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN employee e ON e.emp_id=cs.cs_admin LEFT JOIN user u ON u.usr_id=e.usr_id LEFT JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=4 WHERE ts.jb_id=? AND NOT(ts.trs_srvt_name='z' AND ts.trs_price=0) ORDER BY ts.csc_id,ts.tr_sort,ts.srv_id,ts.srvt_id,ts.trs_srv_name",order)); + if(costs.isEmpty()&&number(job,"jb_totalprice").signum()!=0)throw bad("Die Preisaufstellung ist noch nicht für den Mailversand verfügbar."); + boolean gross=parameter(0,"GLOBAL_USE_RELATED_CUSTOMER").equals("1");BigDecimal factor=gross?BigDecimal.ONE.add(number(job,"jb_sales_tax_rate").divide(BigDecimal.valueOf(100))):BigDecimal.ONE;String basis=t(gross?"brutto":"netto"); + data.append("

").append(escape(t("KOSTEN:"))).append("

");if(!text(job,"jb_orderer").isBlank())data.append("

").append(escape(t("Besteller:"))).append(" ").append(escape(text(job,"jb_orderer"))).append("

"); + data.append("");for(String label:List.of("Kostenstelle","Tel. / Email","EID","Kunde","Leistung"))data.append("");data.append("");BigDecimal sum=BigDecimal.ZERO; + for(var cost:costs){BigDecimal net=number(cost,"trs_price").multiply(BigDecimal.valueOf(100).subtract(number(cost,"trs_discount"))).divide(BigDecimal.valueOf(100)).multiply(factor);sum=sum.add(net); + data.append("");for(String value:List.of(text(cost,"csc_name"),text(cost,"cscad_phone")+"\n"+text(cost,"usr_email"),text(cost,"cs_eid"),text(cost,"cmp_comp")+"\n"+text(cost,"cmp_comp2"),text(cost,"trs_srv_name"),money(net)))data.append("");data.append("");} + data.append("
").append(escape(t(label))).append("").append(escape(t("Betrag"))).append(" ").append(escape(basis)).append("
").append(escape(value).replace("\n","
")).append("

").append(escape(t("KUNDENPREIS:"))).append(" ").append(escape(basis)).append(": ").append(money(sum)).append("

"); + var tolls=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll' ORDER BY gdc_id",order); + if(tolls.size()>1)throw bad("Die Mautposition ist mehrdeutig.");if(!tolls.isEmpty()){toll=text(tolls.getFirst(),"gdc_content");try{BigDecimal outlay=new BigDecimal(toll).multiply(factor);data.append("

").append(escape(t("Maut / Auslagen"))).append(" ").append(escape(basis)).append(": ").append(money(outlay)).append(" · ").append(escape(t("GESAMTSUMME"))).append(" ").append(escape(basis)).append(": ").append(money(sum.add(outlay))).append("

");}catch(NumberFormatException e){throw bad("Die Mautposition ist ungültig.");}} + var discounts=db.queryForList("SELECT jbprc_price,jbprc_remark FROM jobprice WHERE jb_id=? AND mt_sort=13",order);if(discounts.size()>1)throw bad("Der Selbsterfasserrabatt ist mehrdeutig.");if(!discounts.isEmpty()&&number(discounts.getFirst(),"jbprc_price").signum()!=0){var discount=discounts.getFirst();try{BigDecimal base=new BigDecimal(text(discount,"jbprc_remark"));data.append("

").append(escape(t("Selbsterfasserrabatt"))).append(" (").append(escape(number(discount,"jbprc_price"))).append(" % ").append(escape(t("auf"))).append(" ").append(money(base)).append("): ").append(money(base.subtract(base.multiply(BigDecimal.valueOf(100).subtract(number(discount,"jbprc_price"))).divide(BigDecimal.valueOf(100)).setScale(2,RoundingMode.HALF_UP)))).append("

");}catch(NumberFormatException e){throw bad("Die Basis des Selbsterfasserrabatts ist ungültig.");}} + if(number(job,"jb_markup").signum()!=0)data.append("

"+escape(t("Ohne Aufschlag"))+" ("+escape(number(job,"jb_markup"))+" %): "+money(number(job,"jb_subtotalprice"))+"

"); + } + if(delivered&&id(customer,"cs_jbstatusmail_price")==1&&bit(flags,2))data.append(section("Rechnungstext",row("",text(job,"jb_invtext")))); + } + StringBuilder body=new StringBuilder();body.append("
").append(rich(mailText("MAIL_PRE_SALUTATION_TEXT","Sehr geehrte Damen und Herren,",false))).append("

").append(rich(mailText("MAIL_TEXT_"+mode,switch(mode){case "PICKUP"->"die Abholung der Ware erfolgte.";case "DISPOSITION"->"Ihr Transportauftrag befindet sich in der Vermittlung.";case "COMPLETION"->"Ihr Transportauftrag wurde ausgeführt.";case "JOB_TAKEN"->"Ihr Transportauftrag wurde vermittelt.";default->"Ihr Transportauftrag wurde geändert.";},Set.of("CHANGE","COMPLETION","JOB_TAKEN").contains(mode)))).append("

").append(rich(mailText("MAIL_TEXT_JOBDATA","Anbei übersenden wir Ihnen die Auftragsdaten.",false))).append("

"); + if(completed){for(int i=2;i<=5;i++){String extra=local("MAIL_TEXT_COMPLETION_"+i);if(extra.isBlank())break;body.append("

").append(rich(t(extra))).append("

");}} + boolean early=parameter(0,"MAIL_JOBDATA").equals("1"); + String employeeName="",employeeContact=""; + if(completed&&!local("MAIL_SUCCESS_REQUEST").isBlank()&&id(customer,"cs_jbstatusmail_emp")==1){ + var employees=db.queryForList("SELECT usr_firstname,usr_name,usr_phone,usr_email FROM user WHERE usr_id=? AND usr_type=1",id(job,"emp_id")); + if(employees.size()>1)throw bad("Die Ansprechpartnerdaten sind mehrdeutig."); + if(employees.size()==1){var e=employees.getFirst();if(List.of("usr_firstname","usr_name","usr_phone","usr_email").stream().allMatch(k->!text(e,k).isBlank())){ + employeeName=escape(text(e,"usr_firstname")+" "+text(e,"usr_name"));employeeContact=escape(text(e,"usr_phone"))+"
"+escape(text(e,"usr_email")); + if(!local("MAIL_DISPLAY_MODE_EMPLOYEE_DATA").equals("1")){body.append("

").append(rich(t(local("MAIL_SUCCESS_REQUEST")))).append("

").append(employeeName).append("
").append(employeeContact).append("

");employeeName="";employeeContact="";} + }} + } + if(completed)body.append("

").append(rich(fallback("MAIL_TEXT_RECENSION"))).append("

"); + if(early)body.append(data);if(!employeeName.isEmpty())body.append("

").append(rich(local("MAIL_SUCCESS_REQUEST"))).append("

"); + if(completed)body.append(OrderMailSurvey.render(this,job,customerId,seed)); + var info=new ArrayList>();if(!parameter(0,"JOBDETAILS_EMAIL_NO_FOOTER_"+customerId).equals("1")){info.addAll(db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='hq2cs' AND gdc_obj_id=? AND gdc_gen_fieldname='global_cs_email_info' ORDER BY gdc_id",hq));if(info.size()>1)throw bad("Der Kunden-Mailhinweis ist mehrdeutig.");if(!info.isEmpty())body.append("

").append(rich(text(info.getFirst(),"gdc_content"))).append("

");} + 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\"

");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("
"); + for(int i=1;i<=5;i++){String value=fallback("MAIL_FOOTER_RESPONSIBILITY"+(i==1?"":"_"+i));if(value.isBlank())break;body.append("

").append(rich(value)).append("

");}body.append("

").append(rich(local("MAIL_FOOTER_THINK_GREEN"))).append("

");} + body.append("
").append(rich(fallback("MAIL_FOOTER_MANDATORY"))).append("
");if(!early)body.append(data); + String family=NewsletterTemplate.safeStyle("font-family:"+defaulted("MAIL_CSS_FONT_TYPE","Arial, sans-serif")); + String pdfMeta=""; + String html=""+pdfMeta+""+body+""; + if(html.length()>(evidence==null?400000:12_000_000))throw bad("Die Auftragsmail ist zu umfangreich."); + String subject=Jsoup.parse(t("Transportauftrag")+" "+order+switch(mode){case "PICKUP"->": "+t("Die Abholung erfolgte!");case "DISPOSITION"->" "+t("ist in der Vermittlung!");case "COMPLETION"->" "+t("ist erledigt!");default->" "+t("wurde geändert!");}).text().replaceAll("\\s+"," ").strip(); + warnings.addAll(language.warnings()); + return new Content(subject,html,List.copyOf(warnings),LegacyOrderPricing.hash(OrderEntryExtras.json(List.of(job,customer,vehicle,courier,stops,costs,toll,info,parameters,subject,html,archive==null?"":archive.revision(),language.revision))),delivered&&id(customer,"cs_jbstatusmail_pdf")==1); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java b/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java new file mode 100644 index 0000000..eb7adf6 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderDeliveryData.java @@ -0,0 +1,102 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.*; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +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 { + static final int MAX_IMAGE=10*1024*1024,MAX_PIXELS=24_000_000; + final DatabaseService db;final String history; + OrderDeliveryData(DatabaseService db,String history){this.db=db;String schema=history.replace("`","");if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Historienschema");this.history="`"+schema+"`";} + record Picture(String key,String name,long bytes) {} + record Image(String name,String mime,byte[] bytes) {String uri(){return "data:"+mime+";base64,"+Base64.getEncoder().encodeToString(bytes);}} + String value(String object,long id,String field){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_gen_fieldname=?",object,id,field);if(rows.size()>1)throw bad("Ablieferdaten sind mehrdeutig: "+field);return rows.isEmpty()?"":text(rows.getFirst(),"gdc_content");} + List pictures(long station){ + var rows=db.queryForList("SELECT pic_id,OCTET_LENGTH(pic_data) AS bytes FROM "+history+".pic WHERE tr_id=? ORDER BY pic_id LIMIT 101",station); + if(rows.size()>100)throw bad("Mehr als 100 Fotos an einer Station. Bitte den Bestand prüfen."); + if(!rows.isEmpty())return rows.stream().map(r->new Picture("pic-"+id(r,"pic_id"),"Foto-"+id(r,"pic_id"),id(r,"bytes"))).toList(); + return b2bPictures(station); + } + List b2bPictures(long station){ + var rows=db.queryForList("SELECT bo_id,bo_obj_data FROM "+history+".b2b_objects WHERE bo_type=101 AND bo_ext_id0=? ORDER BY bo_id LIMIT 101",station); + if(rows.size()>100)throw bad("Mehr als 100 Fotos an einer Station. Bitte den Bestand prüfen."); + return rows.stream().map(r->new Picture("b2b-"+id(r,"bo_id"),safeName(text(r,"bo_obj_data"),id(r,"bo_id")),0)).toList(); + } + static String safeName(String name,long id){String clean=name.replaceAll("[\\p{Cntrl}\\\\/]","_").strip();return clean.isEmpty()||clean.length()>180?"Foto-"+id:clean;} + Image image(long station,String key){return image(station,key,pictures(station));} + Image jobImage(long job,String key){if(key==null||!key.startsWith("b2b-"))throw bad("Ungültige Auftragsfotokennung.");return image(job,key,b2bPictures(job));} + private Image image(long station,String key,List available){ + if(key==null||!key.matches("(?:pic|b2b)-[1-9][0-9]{0,18}"))throw bad("Ungültige Fotokennung."); + // Membership is rechecked for every binary read; a photo ID alone grants no access. + var photo=available.stream().filter(p->p.key().equals(key)).findFirst().orElseThrow(()->bad("Das Foto gehört nicht zu dieser Station.")); + String encoded; + if(key.startsWith("pic-")){ + if(photo.bytes()>MAX_IMAGE*4L/3+4096)throw bad("Das Foto überschreitet 10 MB."); + var rows=db.queryForList("SELECT pic_data FROM "+history+".pic WHERE pic_id=? AND tr_id=? AND OCTET_LENGTH(pic_data)<=?",Long.parseLong(key.substring(4)),station,MAX_IMAGE*4L/3+4096); + if(rows.size()!=1)throw bad("Das Foto ist nicht mehr verfügbar oder zu groß.");encoded=text(rows.getFirst(),"pic_data"); + }else{ + long parent=Long.parseLong(key.substring(4)); + var sizes=db.queryForList("SELECT bo_id,bo_ext_id2,OCTET_LENGTH(bo_obj_data) AS bytes FROM "+history+".b2b_objects WHERE bo_type=102 AND bo_ext_id0=? ORDER BY CAST(bo_ext_id2 AS UNSIGNED),bo_id LIMIT 1001",parent); + if(sizes.isEmpty()||sizes.size()>1000||sizes.stream().mapToLong(r->id(r,"bytes")).sum()>MAX_IMAGE*4L/3+16000)throw bad("Die gespeicherten Fotoblöcke fehlen oder sind zu groß."); + var seen=new HashSet();StringBuilder joined=new StringBuilder(); + for(var block:sizes){String sequence=text(block,"bo_ext_id2");if(!sequence.matches("[0-9]{1,9}")||!seen.add(Long.toString(Long.parseLong(sequence))))throw bad("Die Reihenfolge der Fotoblöcke ist mehrdeutig."); + String part=db.queryForObject("SELECT bo_obj_data FROM "+history+".b2b_objects WHERE bo_id=? AND bo_type=102 AND bo_ext_id0=? AND OCTET_LENGTH(bo_obj_data)<=?",String.class,id(block,"bo_id"),parent,MAX_IMAGE*4L/3+4096); + if(part==null||!part.startsWith(""))throw bad("Ein gespeicherter Fotoblock ist beschädigt.");joined.append(part,9,part.length()-3);} + encoded=joined.toString(); + } + byte[] bytes;try{bytes=Base64.getDecoder().decode(encoded.replaceAll("\\s",""));}catch(IllegalArgumentException e){throw bad("Das Foto enthält ungültige Bilddaten.");} + String mime=validateImage(bytes);String extension=mime.equals("image/png")?".png":".jpg"; + String name=photo.name().replaceFirst("(?i)\\.(?:jpe?g|png)$","")+extension; + return new Image(name,mime,bytes); + } + static String validateImage(byte[] bytes){ + if(bytes.length==0||bytes.length>MAX_IMAGE)throw bad("Das Foto ist leer oder überschreitet 10 MB."); + try(var input=ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))){ + var readers=ImageIO.getImageReaders(input);if(!readers.hasNext())throw bad("Das Foto ist kein unterstütztes JPEG-/PNG-Bild.");var reader=readers.next(); + try{reader.setInput(input,true,true);String format=reader.getFormatName().toLowerCase(Locale.ROOT);if(!Set.of("png","jpeg","jpg").contains(format))throw bad("Nur JPEG- und PNG-Nachweise werden unterstützt."); + int width=reader.getWidth(0),height=reader.getHeight(0);if(width<1||height<1||width>12000||height>12000||(long)width*height>MAX_PIXELS)throw bad("Das Foto überschreitet die erlaubte Bildgröße."); + if(reader.read(0)==null)throw bad("Das Foto ist beschädigt.");return format.equals("png")?"image/png":"image/jpeg"; + }finally{reader.dispose();} + }catch(IOException e){throw bad("Das Foto ist beschädigt.");} + } + static Image signature(String raw){ + if(raw==null||raw.isBlank())return null; + if(raw.length()>200000)throw bad("Die Unterschrift ist zu umfangreich."); + var strokes=new ArrayList>();int xmax=0,ymax=0,count=0; + for(String cluster:raw.trim().split("[Aa]",-1)){if(cluster.isBlank())continue;var points=new ArrayList(); + for(String token:cluster.split(";",-1)){if(token.isBlank())continue;String[] pair=token.trim().split(",",-1); + if(pair.length!=2||!pair[0].trim().matches("[0-9]{1,4}")||!pair[1].trim().matches("[0-9]{1,4}"))throw bad("Die Unterschrift enthält ungültige Koordinaten."); + int x=Integer.parseInt(pair[0].trim()),y=Integer.parseInt(pair[1].trim());if(x>4095||y>4095||++count>20000)throw bad("Die Unterschrift überschreitet die erlaubte Größe."); + xmax=Math.max(xmax,x);ymax=Math.max(ymax,y);points.add(new Point(x,y));}if(!points.isEmpty())strokes.add(points);} + if(strokes.isEmpty())throw bad("Die Unterschrift enthält keine lesbaren Koordinaten."); + var image=new BufferedImage(xmax+1,ymax+1,BufferedImage.TYPE_INT_RGB);var graphics=image.createGraphics(); + try{graphics.setColor(Color.WHITE);graphics.fillRect(0,0,image.getWidth(),image.getHeight());graphics.setColor(Color.BLACK); + for(var stroke:strokes){Point previous=null;for(var point:stroke){if(previous==null)graphics.fillRect(point.x,point.y,1,1);else graphics.drawLine(previous.x,previous.y,point.x,point.y);previous=point;}} + }finally{graphics.dispose();} + try{var out=new ByteArrayOutputStream();ImageIO.write(image,"png",out);return new Image("Unterschrift.png","image/png",out.toByteArray());}catch(IOException e){throw new IllegalStateException(e);} + } + static String arrival(String raw){if(raw.isBlank())return "";String stamp=raw.split("\\|",-1)[0].strip();if(stamp.length()<14)throw bad("Die Ankunftszeit ist ungültig.");try{return LocalDateTime.parse(stamp.substring(0,14),DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(java.time.format.ResolverStyle.STRICT)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"));}catch(java.time.DateTimeException e){throw bad("Die Ankunftszeit ist ungültig.");}} + static String publicRemark(String raw){return raw.replaceAll("(?s)\\[\\[.*?(?:]]|$)","").strip();} + Map station(long station){var out=new LinkedHashMap();for(String key:List.of("arr_tour","fin_work","del_code","del_rem","tr_hint","tr_int_rem"))out.put(key,value("tr",station,key));return out;} + static String deliveryLabel(String code,long direction,String configured){ + if(code.isBlank())return "";if(code.equals("1")&&direction==1)return "Abgeholt";if(code.equals("1")&&(direction==2||direction==3))return "Abgeliefert"; + if(!configured.isBlank()){ + var entries=new HashMap();for(String item:configured.split("\\|")){String[] pair=item.split("=",2);if(pair.length!=2||entries.putIfAbsent(pair[0],pair[1])!=null)throw bad("Die Abliefercodes sind mehrdeutig oder ungültig.");}return entries.getOrDefault(code,"Code "+code); + } + var labels=List.of("Ok","1 NA,nicht angetroffen","2 NA,Betriebsferien,Streik","3 AV,Ware gefällt nicht","4 Kd Verursacher,sonst.Gründe","5 Adressklärung,Anl.nicht möglich","6 AV,Bestelldaten sind falsch","7 AV,Auftrag doppelt erfasst","8 Sonstige Gründe","9 FP,Fehlpaket","10 AV,Richtiger Artikel auf LS","11 Paket im Lager beschädigt","12 Verspätete Abf.Nachtsprung LKW","13 Pakete im falschen RDC gelandet","14 Nachtspr.LKW verspätet im RDC","15 Besch.Pak.durch Nachtsprung","16 Pakete im RDC vertauscht","17 Aus Zeitgründen nicht ausgel.","18 Besch.Pak.durch Fahrer/RDC","19 Sonstiges,Fehler Versandteam","20 Autobahnvollsp.,höh.Gewalt","21 Sonstiges"); + try{int index=Integer.parseInt(code);return index>=0&&index internal(long job,long station){String raw=value("jb",job,"int_rem_item"),codes="";var keys=new HashSet();for(String item:raw.split("\\|")){if(item.isBlank())continue;String[] pair=item.split("=",2);if(pair.length!=2||!keys.add(pair[0]))throw bad("Die Innendienstbemerkungen sind mehrdeutig.");if(pair[0].equals(Long.toString(station)))codes=pair[1];} + var result=new ArrayList();for(String code:codes.split(",")){if(code.isBlank())continue;if(!code.matches("[0-9]{1,6}"))throw bad("Ungültige Innendienstbemerkung.");var rows=db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='internal_remark' AND mt_objtype='' AND mt_objid=0 AND mt_sort=?",Integer.parseInt(code));if(rows.size()!=1)throw bad("Eine Innendienstbemerkung ist nicht mehr eindeutig definiert.");result.add(text(rows.getFirst(),"mt_value"));}return result;} + List> articles(long job,int position){return db.queryForList("SELECT * FROM tourarticle WHERE jb_id=? AND tr_sort=? ORDER BY trat_sort,trat_id LIMIT 1001",job,position);} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderDeliveryEvidence.java b/backend/src/main/java/de/stadtbote/votian/OrderDeliveryEvidence.java new file mode 100644 index 0000000..9421ee5 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderDeliveryEvidence.java @@ -0,0 +1,35 @@ +package de.stadtbote.votian; + +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; + +@RestController +@RequestMapping("/api/orders/{order}/delivery-evidence") +class OrderDeliveryEvidence { + final DatabaseService db;final OrderDeliveryData data; + OrderDeliveryEvidence(DatabaseService db,BusinessSupport support){this.db=db;this.data=new OrderDeliveryData(db,support.historySchema);} + @GetMapping Object view(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);} + @GetMapping("/{station}/images/{key}") Object image(Authentication a,@PathVariable long order,@PathVariable long station,@PathVariable String key){return image(ApiController.user(a),order,station,key);} + LegacyUser access(LegacyUser actor,long order){actor.require(4);var user=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());user.require(4);one(db.queryForList("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",order,user.headquartersId()));return user;} + Map view(LegacyUser actor,long order){return db.readTransaction(()->{ + var user=access(actor,order);var customers=db.queryForList("SELECT cs.cs_id FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer JOIN customer cs ON cs.cs_id=cc.cs_id WHERE j.jb_id=?",order); + if(customers.size()>1)throw bad("Der Rechnungskunde ist mehrdeutig.");String codes=customers.isEmpty()?"":new OrderCustomerMailContent(db,user.headquartersId(),"").object("JOBDETAILS_STATIONS_DELIVERY_CODES",id(customers.getFirst(),"cs_id")); + var stops=db.queryForList("SELECT tr_id,tr_sort,tr_comp,tr_status,tr_ware_from_to,tr_finishtime,tr_signname,COALESCE(CHAR_LENGTH(tr_sign),0) AS signatureSize FROM tour WHERE jb_id=? ORDER BY tr_sort,tr_id LIMIT 101",order); + if(stops.size()>100)throw bad("Mehr als 100 Stationen. Bitte die Auswahl eingrenzen.");var items=new ArrayList>(); + for(var stop:stops){long station=id(stop,"tr_id");var values=data.station(station);var warnings=new ArrayList();String arrival="",workFinished="";try{arrival=OrderDeliveryData.arrival(values.get("arr_tour"));}catch(org.springframework.web.server.ResponseStatusException e){warnings.add(e.getReason());} + try{workFinished=OrderDeliveryData.arrival(values.get("fin_work"));}catch(org.springframework.web.server.ResponseStatusException e){warnings.add("Arbeitsbeendigungszeit: "+e.getReason());} + var photos=data.pictures(station);var articles=data.articles(order,(int)id(stop,"tr_sort"));if(articles.size()>1000)throw bad("Zu viele Artikel an einer Station."); + items.add(fields("id",station,"position",id(stop,"tr_sort"),"name",text(stop,"tr_comp"),"completed",id(stop,"tr_status")==1,"finishedAt",dateTime(stop,"tr_finishtime"),"recipient",text(stop,"tr_signname"),"signature",id(stop,"signatureSize")>0,"arrival",arrival,"workFinished",workFinished,"deliveryCode",values.get("del_code"),"deliveryLabel",OrderDeliveryData.deliveryLabel(values.get("del_code"),id(stop,"tr_ware_from_to"),codes),"deliveryRemark",values.get("del_rem"),"courierNote",values.get("tr_hint"),"internalNote",values.get("tr_int_rem"),"internalItems",data.internal(order,station),"photos",photos,"warnings",warnings,"articles",articles.stream().map(r->fields("id",id(r,"trat_id"),"name",text(r,"trat_name"),"serial",text(r,"trat_serialno"),"quantity",id(r,"trat_quantity"),"pieces",id(r,"trat_packingpieces"),"remark",text(r,"trat_remark"),"state",id(r,"trat_state"))).toList()));} + return fields("stationNumbers",new OrderCustomerMailContent(db,user.headquartersId(),"").object(OrderDetailOptions.STATION_NUMBERS,customers.isEmpty()?0:id(customers.getFirst(),"cs_id")).equals("1"),"order",order,"stations",items,"source","Nachweise aus den aktuellen Stationsdaten."); + });} + Map image(LegacyUser actor,long order,long station,String key){return db.readTransaction(()->{ + access(actor,order);var row=one(db.queryForList("SELECT tr_sign FROM tour WHERE tr_id=? AND jb_id=?",station,order)); + var image=key.equals("signature")?OrderDeliveryData.signature(text(row,"tr_sign")):data.image(station,key);if(image==null)throw bad("Keine Unterschrift vorhanden."); + return fields("name",image.name(),"mime",image.mime(),"data",image.uri()); + });} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderDetailMail.java b/backend/src/main/java/de/stadtbote/votian/OrderDetailMail.java new file mode 100644 index 0000000..f5efd61 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderDetailMail.java @@ -0,0 +1,68 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +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; + +@RestController +@RequestMapping("/api/orders/{order}/mail") +class OrderDetailMail { + record Draft(String recipients,String previousToken,String editRequest) { Draft(String recipients,String previousToken){this(recipients,previousToken,"");} } + record Confirmation(String token,boolean recipientsChecked) {} + record Snapshot(OrderEntryRequestMail.Envelope envelope,OrderCustomerMailContent.Content content,String revision) {} + record Delivery(String token,long headquarters,long user,long order,String subject,OrderEntryRequestMail.Envelope envelope,String startedAt,String state) {} + record Pending(long headquarters,long home,long user,long order,Draft draft,Snapshot snapshot,byte[] pdf,LocalDate day,Instant expires) {} + final DatabaseService db;final BusinessSupport support;final AppointmentMailTransport transport;final String legacy,defaultCc,defaultBcc; + final Map pending=new LinkedHashMap<>(); + OrderDetailMail(DatabaseService db,BusinessSupport support,AppointmentMailTransport transport,@Value("${app.legacy-url:}")String legacy,@Value("${app.mail.order-cc:}")String cc,@Value("${app.mail.order-bcc:}")String bcc){this.db=db;this.support=support;this.transport=transport;this.legacy=legacy;defaultCc=cc;defaultBcc=bcc;} + @GetMapping Object options(Authentication a,@PathVariable long order,@RequestParam(defaultValue="") String editRequest){return options(ApiController.user(a),order,editRequest);} + @GetMapping("/status") Object status(Authentication a,@PathVariable long order){return status(ApiController.user(a),order);} + @PostMapping("/preview") Object preview(Authentication a,@PathVariable long order,@RequestBody Draft draft){return preview(ApiController.user(a),order,draft);} + @PostMapping("/send") Object send(Authentication a,@PathVariable long order,@RequestBody Confirmation confirmation){return send(ApiController.user(a),order,confirmation);} + @GetMapping("/preview/{token}/pdf/{page}") Object pdf(Authentication a,@PathVariable long order,@PathVariable String token,@PathVariable int page){var u=access(ApiController.user(a),order);var p=pending(u,order,token);if(p.pdf()==null)throw bad("Diese Mail hat keinen PDF-Anhang.");return VehicleRollcardPdf.page(p.pdf(),page);} + LegacyUser access(LegacyUser actor,long order){actor.require(4);actor.require(7);var user=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());user.require(4);user.require(7);job(user,order);return user;} + Map job(LegacyUser user,long order){return one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",order,user.headquartersId()));} + Delivery decode(Map row,long order,long headquarters){try{var d=support.json.readValue(text(row,"gdc_content"),Delivery.class);if(d.order()!=order||d.headquarters()!=headquarters||!Set.of("STARTED","SENT","UNCERTAIN").contains(d.state()))throw new IllegalArgumentException();return d;}catch(Exception e){throw bad("Der Versandstatus ist nicht eindeutig lesbar. Bitte den Versand prüfen.");}} + Delivery latest(LegacyUser user,long order){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngdetmail' AND gdc_obj_id=? ORDER BY gdc_id DESC LIMIT 1",order);return rows.isEmpty()?null:decode(rows.getFirst(),order,user.headquartersId());} + Delivery stored(LegacyUser user,long order,String token){var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngdetmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,token);if(rows.size()>1)throw bad("Der Versandstatus ist mehrdeutig.");return rows.isEmpty()?null:decode(rows.getFirst(),order,user.headquartersId());} + static Map result(Delivery d){return d==null?fields("state","NOT_STARTED","token","","message","Noch keine manuelle Auftragsmail gestartet."):fields("token",d.token(),"order",d.order(),"state",d.state(),"startedAt",d.startedAt(),"subject",d.subject(),"to",d.envelope().to(),"cc",d.envelope().cc(),"bcc",d.envelope().bcc(),"message",d.state().equals("SENT")?"E-Mail vom SMTP-Server angenommen. Dies bestätigt noch nicht die Zustellung.":"Die Übergabe ist noch nicht sicher bestätigt. Bitte Zustellung oder SMTP-Protokolle prüfen; kein automatischer Neuversand.");} + Map status(LegacyUser actor,long order){return db.readTransaction(()->result(latest(access(actor,order),order)));} + Map options(LegacyUser actor,long order){return options(actor,order,"");} + Map options(LegacyUser actor,long order,String editRequest){return db.readTransaction(()->{var user=access(actor,order);String reason="",mode="";List recipients=List.of(),warnings=List.of();try{if(editRequest!=null&&!editRequest.isBlank()){var edit=OrderEditMail.eligibility(db,support,user,job(user,order),editRequest);mode="CHANGE";recipients=edit.recipients();warnings=edit.warnings();}else mode=new OrderCustomerMailContent(db,user.headquartersId(),legacy).mode(job(user,order));}catch(org.springframework.web.server.ResponseStatusException e){reason=e.getReason();}return fields("mode",mode,"reason",reason,"recipients",recipients,"warnings",warnings,"configured",transport.configured(),"delivery",result(latest(user,order)));});} + void expected(Delivery latest,String token){String previous=Objects.toString(token,"");if(latest!=null&&!latest.state().equals("SENT"))throw bad("Ein früherer Versand ist noch nicht sicher bestätigt. Bitte zuerst den Versandstatus prüfen.");if(!Objects.equals(latest==null?"":latest.token(),previous))throw OrderEntryService.conflict("Der Versandstatus wurde geändert. Bitte neu laden, bevor eine weitere Mail vorbereitet wird.");} + Snapshot snapshot(LegacyUser user,long order,Draft draft,LocalDate day,String seed){ + if(draft==null)throw bad("Bitte Empfänger angeben.");var to=OrderEntryMail.addresses(draft.recipients());if(to.isEmpty())throw bad("Bitte mindestens einen Empfänger angeben."); + var content=new OrderCustomerMailContent(db,user.headquartersId(),legacy);String from=content.local("MAIL_SENDER_ADDRESS");if(from.isBlank())from=transport.from;from=AppointmentNotifications.address(from); + String cc=content.local("MAIL_CC_ADDRESS"),bcc=content.local("MAIL_BCC_ADDRESS");var seen=new HashSet();to.forEach(v->seen.add(v.toLowerCase(Locale.ROOT))); + var copies=OrderEntryMail.addresses(cc.isBlank()?defaultCc:cc).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();var hidden=OrderEntryMail.addresses(bcc.isBlank()?defaultBcc:bcc).stream().filter(v->seen.add(v.toLowerCase(Locale.ROOT))).toList();if(seen.size()>20)throw bad("Höchstens 20 Empfänger einschließlich Kopien sind erlaubt."); + var envelope=new OrderEntryRequestMail.Envelope(from,to,copies,hidden);var job=job(user,order);boolean changed=draft.editRequest()!=null&&!draft.editRequest().isBlank();var edit=changed?OrderEditMail.eligibility(db,support,user,job,draft.editRequest()):null; + var archive=dateTime(job,"jb_export_time")==null?null:new OrderArchive(db).load(order);var rendered=content.build(job,day,new OrderDeliveryData(db,support.historySchema),seed,archive,changed); + if(edit!=null){var warnings=new ArrayList<>(rendered.warnings());warnings.addAll(edit.warnings());rendered=new OrderCustomerMailContent.Content(rendered.subject(),rendered.html(),List.copyOf(warnings),rendered.revision(),rendered.pdf());} + return new Snapshot(envelope,rendered,revision(List.of(envelope,rendered.revision(),rendered.pdf(),edit==null?"":edit.revision()))); + } + Map preview(LegacyUser actor,long order,Draft draft){var user=access(actor,order);LocalDate day=LocalDate.now(ZoneId.of("Europe/Berlin"));String token=UUID.randomUUID().toString();var snapshot=db.readTransaction(()->{if(draft==null)throw bad("Bitte Empfänger angeben.");expected(latest(user,order),draft.previousToken());return snapshot(user,order,draft,day,token);});byte[] pdf=snapshot.content().pdf()?OrderMailPdf.create(order,snapshot.content().html()):null; + synchronized(pending){pending.values().removeIf(p->p.expires().isBefore(Instant.now()));if(pending.size()>=20||pending.values().stream().mapToLong(p->p.snapshot().content().html().length()*2L+(p.pdf()==null?0:p.pdf().length)).sum()+snapshot.content().html().length()*2L+(pdf==null?0:pdf.length)>80_000_000)throw bad("Zu viele offene Mailvorschauen. Bitte später erneut versuchen.");pending.put(token,new Pending(user.headquartersId(),user.originalHeadquartersId(),user.userId(),order,draft,snapshot,pdf,day,Instant.now().plusSeconds(900)));} + var e=snapshot.envelope();return fields("token",token,"from",e.from(),"to",e.to(),"cc",e.cc(),"bcc",e.bcc(),"subject",snapshot.content().subject(),"html",snapshot.content().html(),"warnings",snapshot.content().warnings(),"configured",transport.configured(),"pdf",pdf==null?null:fields("name","Auftrag_"+order+".pdf","data",Base64.getEncoder().encodeToString(pdf))); + } + Pending pending(LegacyUser user,long order,String token){BusinessSupport.token(token);Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.user()!=user.userId()||p.home()!=user.originalHeadquartersId()||p.headquarters()!=user.headquartersId()||p.order()!=order||p.expires().isBefore(Instant.now()))throw bad("Die Vorschau ist abgelaufen oder gehört zu einem anderen Vorgang. Bitte neu laden.");return p;} + Map send(LegacyUser actor,long order,Confirmation confirmation){ + var user=access(actor,order);if(confirmation==null||!confirmation.recipientsChecked())throw bad("Bitte Inhalt, Anhänge und alle Empfänger ausdrücklich bestätigen.");BusinessSupport.token(confirmation.token());var previous=stored(user,order,confirmation.token());if(previous!=null)return result(previous); + var p=pending(user,order,confirmation.token());if(!transport.configured())throw bad("Der Mailversand ist noch nicht konfiguriert.");support.transactional("user","genericdatacontainer"); + boolean claimed=db.orderDetailMailWriteTransaction(support.historySchema.replace("`",""),()->{ + var currentUser=access(actor,order);if(stored(currentUser,order,confirmation.token())!=null)return false;expected(latest(currentUser,order),p.draft().previousToken()); + var current=snapshot(currentUser,order,p.draft(),p.day(),confirmation.token());if(!current.revision().equals(p.snapshot().revision()))throw OrderEntryService.conflict("Auftrag, Nachweise, Mailvorgaben oder Empfänger wurden geändert. Bitte die Vorschau neu laden."); + var claim=new Delivery(confirmation.token(),user.headquartersId(),user.userId(),order,p.snapshot().content().subject(),p.snapshot().envelope(),LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString(),"STARTED"); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngdetmail","gdc_obj_id",order,"gdc_gen_fieldname",confirmation.token(),"gdc_context",Long.toString(user.userId()),"gdc_content",support.encode(claim)));support.audit(currentUser,order,0,0,203,"Manuelle Auftragsmail zur Übergabe vorgemerkt",confirmation.token());return true; + }); + if(!claimed)return result(stored(user,order,confirmation.token()));String state="SENT"; + try{var e=p.snapshot().envelope();transport.sendOrderHtml(e.from(),e.to(),e.cc(),e.bcc(),p.snapshot().content().subject(),p.snapshot().content().html(),"Auftrag_"+order+".pdf",p.pdf());}catch(RuntimeException e){state="UNCERTAIN";} + var claim=stored(user,order,confirmation.token());var done=new Delivery(claim.token(),claim.headquarters(),claim.user(),claim.order(),claim.subject(),claim.envelope(),claim.startedAt(),state); + db.writeTransaction(()->{db.update("UPDATE genericdatacontainer SET gdc_content=? WHERE gdc_obj_type='ngdetmail' AND gdc_obj_id=? AND gdc_gen_fieldname=?",support.encode(done),order,done.token());support.audit(user,order,0,0,done.state().equals("SENT")?22:23,done.state().equals("SENT")?"Manuelle Auftragsmail vom SMTP-Server angenommen":"Übergabe der manuellen Auftragsmail unklar; kein automatischer Neuversand",done.token()+":result");return null;});synchronized(pending){pending.remove(confirmation.token());}return result(done); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderDetailOptions.java b/backend/src/main/java/de/stadtbote/votian/OrderDetailOptions.java new file mode 100644 index 0000000..2124347 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderDetailOptions.java @@ -0,0 +1,24 @@ +package de.stadtbote.votian; +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; +/** Customer -> headquarters -> global display policy in jb_detail_history.inc.php. */ +@RestController +@RequestMapping("/api/orders/{order}/detail-options") +class OrderDetailOptions { + static final String STATION_NUMBERS="JOBDETAILS_STATIONS_STATION_NUMBER"; + final DatabaseService db; + OrderDetailOptions(DatabaseService db){this.db=db;} + @GetMapping Object get(Authentication a,@PathVariable long order){return view(ApiController.user(a),order);} + Map view(LegacyUser actor,long order){actor.require(4);return db.readTransaction(()->{var u=new WorkspaceHeadquarters(db).select(actor,actor.headquartersId());u.require(4);var row=one(db.queryForList("SELECT jb_id,jb_id_parent,hq_id,csc_id_payer FROM job WHERE jb_id=? AND hq_id=?",order,u.headquartersId()));return fields("displayNumber",displayNumber(new OrderEntryCatalog(db),row),"stationNumbers",stationNumbers(db,id(row,"hq_id"),id(row,"csc_id_payer")));});} + static String displayNumber(OrderEntryCatalog catalog,Map job){ + long number=id(job,"jb_id"),parent=id(job,"jb_id_parent"); + if(!catalog.enabled(0,"GLOBAL_USE_JB_PARENT_ID_FOR_INVOICE_NO")||!catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"))return Long.toString(number); + return parent>0?parent+"/2":number+"/1"; + } + static boolean stationNumbers(DatabaseService db,long hq,long payer){var customers=db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",payer);if(customers.size()>1)throw bad("Die Rechnungskostenstelle ist mehrdeutig.");long customer=customers.isEmpty()?0:id(customers.getFirst(),"cs_id");return new OrderCustomerMailContent(db,hq,"").object(STATION_NUMBERS,customer).equals("1");} +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEditMail.java b/backend/src/main/java/de/stadtbote/votian/OrderEditMail.java new file mode 100644 index 0000000..e75d5f1 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEditMail.java @@ -0,0 +1,37 @@ +package de.stadtbote.votian; + +import de.stadtbote.votian.database.DatabaseService; +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; + +/** A committed edit offers a separate, explicitly reviewed CHANGE mail. */ +final class OrderEditMail { + record Receipt(long user,long home,long headquarters,long order,String request,String at) {} + record Eligibility(String revision,List recipients,List warnings) {} + static void remember(DatabaseService db,BusinessSupport support,LegacyUser user,long order,String request){ + var receipt=new Receipt(user.userId(),user.originalHeadquartersId(),user.headquartersId(),order,BusinessSupport.token(request),LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString()); + db.insert("genericdatacontainer",fields("gdc_obj_type","ngedit","gdc_obj_id",order,"gdc_gen_fieldname",request,"gdc_context",Long.toString(user.userId()),"gdc_content",support.encode(receipt))); + } + static Receipt decode(BusinessSupport support,Map row){try{return support.json.readValue(text(row,"gdc_content"),Receipt.class);}catch(Exception e){throw bad("Der gespeicherte Bearbeitungsvorgang ist nicht lesbar.");}} + static boolean owns(Receipt receipt,LegacyUser user,long order){return receipt!=null&&receipt.user()==user.userId()&&receipt.home()==user.originalHeadquartersId()&&receipt.headquarters()==user.headquartersId()&&receipt.order()==order;} + static String latest(DatabaseService db,BusinessSupport support,LegacyUser user,long order){ + var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngedit' AND gdc_obj_id=? AND gdc_context=? ORDER BY gdc_id DESC LIMIT 1",order,Long.toString(user.userId())); + if(rows.isEmpty())return "";var receipt=decode(support,rows.getFirst());return owns(receipt,user,order)?BusinessSupport.token(receipt.request()):""; + } + static Eligibility eligibility(DatabaseService db,BusinessSupport support,LegacyUser user,Map job,String request){ + long order=id(job,"jb_id");BusinessSupport.token(request); + var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngedit' AND gdc_obj_id=? AND gdc_gen_fieldname=?",order,request); + if(rows.size()!=1)throw bad("Der gespeicherte Bearbeitungsvorgang ist für diese Änderungsmail nicht verfügbar."); + var receipt=decode(support,rows.getFirst());if(!owns(receipt,user,order)||!request.equals(receipt.request()))throw bad("Der Bearbeitungsvorgang gehört zu einem anderen Konto, Auftrag oder Arbeitsplatz."); + if(id(job,"jb_offer")!=0||id(job,"jb_storno")!=0)throw bad("Für Angebote oder stornierte Aufträge ist diese Änderungsmail nicht vorgesehen."); + if(OrderOperations.exported(job))throw bad("Der bearbeitete Auftrag ist inzwischen exportiert. Bitte die historische Mail in den Auftragsdetails verwenden."); + long payer=id(job,"csc_id_payer_cash")>0?id(job,"csc_id_payer_cash"):id(job,"csc_id_payer"); + var policy=new OrderEntryMail(db,new OrderEntryCatalog(db)).policy(user,payer,id(job,"jb_payment")==2?OrderEntryModels.Payment.CASH:OrderEntryModels.Payment.INVOICE,(int)id(job,"vht_id")); + if(policy.requestRecipients().isEmpty())throw bad("Die aktuellen Kundenvorgaben sehen keine bestätigte Änderungsmail vor oder es fehlt eine gültige Standardadresse."); + var warnings=new ArrayList<>(policy.warnings());if(id(job,"jb_incomplete")==1)warnings.add("Der Auftrag ist noch zur Nachbearbeitung markiert. Bitte insbesondere Route und Preise vor dem Versand prüfen."); + return new Eligibility(revision(List.of(receipt,policy.revision())),policy.requestRecipients(),warnings); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java index 979e045..37e0a92 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryAccounting.java @@ -23,7 +23,7 @@ final class OrderEntryAccounting { if(request.costSplit()) { if(request.payment()!=Payment.INVOICE || quote.batch())throw invalid("Kostensplit benötigt einen direkt abgerechneten Rechnungsauftrag."); var centers=new TreeMap>(); - centers.put(request.payerCostCenterId(),catalog.costCenter(user,request.payerCostCenterId(),true)); + centers.put(request.payerCostCenterId(),catalog.billingCenter(user,request.payerCostCenterId(),true)); for(var stop:request.stops())if(stop.costCenterId()!=null) { var center=catalog.costCenter(user,stop.costCenterId(),false); if(id(center,"csc_is_extern")==0)centers.put(stop.costCenterId(),center); @@ -32,17 +32,20 @@ final class OrderEntryAccounting { int count=centers.size(),index=0; for(var entry:centers.entrySet()) { final int part=index++; - BigDecimal net=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,BigDecimal::add); 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))); } } var couriers=new ArrayList(); + OrderCourierParentPolicy parentPolicy=null; if(!request.courierShares().isEmpty()) { user.require(38); if(request.mode()!=Mode.RETROSPECTIVE || request.courierVehicleId()==null || request.stops().stream().anyMatch(s -> !s.country().equals("DE"))) throw invalid("Kurier-Unteraufträge sind wie im Bestand nur bei Nacherfassungen im Inland mit ausführendem Fahrzeug möglich."); if(quote.batch() || (quote.route()!=null && quote.route().longhaul()))throw invalid("Kurier-Unteraufträge sind für diesen Abrechnungsmodus nicht verfügbar."); + parentPolicy=OrderCourierParentPolicy.read(catalog,user.headquartersId()); + if(parentPolicy.enabled())parentPolicy.require(text(catalog.vehicle(user,request.courierVehicleId()),"crvh_sid")); if(request.pricing().courierPrice()!=null || request.pricing().courierServicePrice()!=null || request.pricing().courierMarkup()!=null) 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); @@ -76,7 +79,7 @@ final class OrderEntryAccounting { 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); } - tree.put("fingerprint",hash(tree.toString()+"|"+request.costSplit()+"|"+request.courierShares()+"|"+request.articles())); + tree.put("fingerprint",hash(quoteEvidence(tree)+"|"+request.costSplit()+"|"+request.courierShares()+"|"+request.articles()+(couriers.isEmpty()?"":"|"+OrderChildInvoiceTexts.policy(catalog,user.headquartersId())+"|"+parentPolicy))); try{return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);} } /** Residual cents go to the first stable IDs; the sum is always exact. */ @@ -113,12 +116,13 @@ final class OrderEntryAccounting { BigDecimal factor=BigDecimal.valueOf(sign); var job=fields("hq_id",user.headquartersId(),"vht_id",parentJob.get("vht_id"),"csc_id_payer",item.payerId(),"jb_payment",parentJob.get("jb_payment"), "jb_status",2,"jb_autoranking",0,"emp_id",user.userId(),"jb_postage",0,"jb_invmode",quote.invoiceMode(),"jb_freetext_1","", + "jb_service","0","jb_orderer",text(parentJob,"jb_orderer"),"jb_scrm","","jb_freetext_2","²²²", "jb_incomplete",parentJob.get("jb_incomplete"),"jb_sales_tax_rate",item.taxRate(),"jb_sales_tax_rate_sign",item.taxSign(), "jb_ordertime",prepared.at(),"jb_taketime",prepared.at(),"jb_finishtime",prepared.at(),"jb_booktime",prepared.now(),"jb_createtime",prepared.now(), "jb_totalprice",item.net().multiply(factor),"jb_cr_price",item.net().multiply(factor),"jb_fixprice",item.base().multiply(factor), "jb_subtotalprice",item.subtotal().multiply(factor),"jb_serviceprice",item.service().multiply(factor),"jb_markup",quote.markup(), "cr_id",courier,"cr_sid",sid,"cr_id_order",courier,"jb_id_parent",parent,"jb_tourdata",parentJob.get("jb_tourdata"), - "jb_invtext","Unternehmerverrechnung zu Auftrag Nr. "+original,"jb_invtext_cr",parentJob.get("jb_invtext_cr")); + "jb_type",id(parentJob,"jb_type"),"jb_invtext",OrderChildInvoiceTexts.render(original,text(parentJob,"jb_invtext_cr"),OrderChildInvoiceTexts.policy(catalog,user.headquartersId())),"jb_invtext_cr",parentJob.get("jb_invtext_cr")); long id=db.insert("job",job); 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,'',?,?)", id,item.payerId(),markup(item.base(),quote.markup()).multiply(factor),item.discount()); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java index a36f0ac..fddb6f6 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryAudit.java @@ -38,6 +38,14 @@ public class OrderEntryAudit { db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING")); }catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);} } + void enqueueThreshold(long orderId,LegacyUser user,LocalDateTime at,String requestId,OrderCourierThreshold.Warning warning) { + try {String content=json.writeValueAsString(new Event(user.userId(),user.headquartersId(),at.toString(),OrderCourierThreshold.description(warning)+"|NG_REQUEST="+requestId,153));db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING"));} + catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);} + } + void enqueueThirdCountry(long orderId,LegacyUser user,LocalDateTime at,String requestId) { + try {String content=json.writeValueAsString(new Event(user.userId(),user.headquartersId(),at.toString(),"Drittlandtransport: Versenderstatus vor der Abrechnung anfordern|NG_REQUEST="+requestId,151));db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING"));} + catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);} + } void tryPublish(long orderId) { try { publish(orderId); } catch(RuntimeException e) { @@ -54,7 +62,7 @@ public class OrderEntryAudit { Event event; try {event=json.readValue(text(row,"gdc_content"),Event.class);} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Ungültiger Protokollauftrag.",e);} - int operation=event.operation()==154?154:1; + int operation=java.util.Set.of(151,153,154).contains(event.operation())?event.operation():1; var existing=db.queryForList("SELECT log_id FROM "+logTable+" WHERE jb_id=? AND logo_id=? AND logo_description=? LIMIT 1",orderId,operation,event.description()); if(existing.isEmpty())db.update("INSERT INTO "+logTable+" (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,?,?,0,'',0,0,0,0,?,?)", operation,event.headquartersId(),orderId,event.userId(),event.description(),LocalDateTime.parse(event.at())); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryBwv.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryBwv.java new file mode 100644 index 0000000..299f148 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryBwv.java @@ -0,0 +1,17 @@ +package de.stadtbote.votian; +import de.stadtbote.votian.database.DatabaseService; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** Explicit retrospective BWV marker; independent of automatic longhaul routing. */ +final class OrderEntryBwv { + static void validate(LegacyUser user,Request request){if(Boolean.TRUE.equals(request.bwv())&&(user.customerAccount()||request.mode()!=Mode.RETROSPECTIVE))throw invalid("Das BWV-Kennzeichen ist nur bei einer zentralen Nacherfassung möglich.");} + static Quote quote(Request request,Quote quote){return Boolean.TRUE.equals(request.bwv())?quote.withMediationDeadline(quote.mediationDeadline(),LegacyOrderPricing.hash(quote.fingerprint()+"|bwv|true")):quote;} + static void copy(DatabaseService db,long id,Map data){ + var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_bwv' ORDER BY gdc_id",id); + if(rows.size()>1)throw invalid("Das BWV-Kennzeichen der Vorlage ist mehrfach vorhanden."); + data.put("bwv","RETROSPECTIVE".equals(data.get("entryMode"))&&!rows.isEmpty()&&text(rows.getFirst(),"gdc_content").equals("1")); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java index 4b9b410..5cb3f7c 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryCatalog.java @@ -23,7 +23,7 @@ public class OrderEntryCatalog { cs.csc_id_payer,cs.cs_discount,cs.cs_fixprice_discount,cs.cs_invmode,cs.cs_jbcond, cs.cs_jobbatch,cs.cs_markup,cs.cs_markup_prov,cmp.cmp_postage,cs.hq_id, cs.cs_jb_commission_no,cs.cs_commission_no,cs.cs_id_parent,cmp.cmp_sustainability_email, - cs.cs_filter,cmp.tx_id,cmp.cmp_stax_idno + cs.cs_filter,cs.cs_tracking,cmp.tx_id,cmp.cmp_stax_idno,cmp.cmp_remark """; static final String VEHICLE_FROM = """ FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id @@ -36,7 +36,10 @@ public class OrderEntryCatalog { public Options options(LegacyUser user) { user.require(7); - return db.readTransaction(() -> new Options( + return db.readTransaction(() -> { + var modes=OrderEntryModes.read(db,user); + String invoiceText=parameter(user.headquartersId(),"MASK_HIDDEN_FREETEXT"); + return new Options( db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort") .stream().map(r -> new Option(id(r,"mt_sort"),text(r,"mt_value"))).toList(), db.queryForList("SELECT tx_id,tx_sign,tx_value FROM tax ORDER BY tx_id").stream() @@ -45,7 +48,8 @@ public class OrderEntryCatalog { new Option(4,"Wöchentlich"),new Option(5,"Vierzehntäglich"),new Option(6,"Monatlich"), new Option(7,"Zum Monatsende"),new Option(8,"15. und Monatsende"),new Option(9,"Feiertags"), new Option(10,"1. und 16."),new Option(11,"Vierwöchentlich")), - user.hasRight(38),user.hasRight(39),Arrays.stream(parameter(0,"EU_COUNTRYCODES").split("[^A-Z]+")).filter(v -> v.matches("[A-Z]{2}")).toList())); + user.hasRight(38),user.hasRight(39),Arrays.stream(parameter(0,"EU_COUNTRYCODES").split("[^A-Z]+")).filter(v -> v.matches("[A-Z]{2}")).toList(),modes,OrderEntryLimits.read(this,user.headquartersId()),enabled(user.headquartersId(),"MASK_MANUAL_DISPOSITION"),invoiceText.equals("1")||invoiceText.equals("2")&&modes.laterAccounting(),enabled(user.headquartersId(),"MASK_CHECK_DISCOUNT"),enabled(user.headquartersId(),"MASK_SERVICE_PRICE")&&!parameter(user.headquartersId(),"MASK_MARKUP_MODE").equals("2")); + }); } public List costCenters(LegacyUser user,String query) { @@ -55,7 +59,20 @@ public class OrderEntryCatalog { " AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR cc.csc_name LIKE ? OR CAST(cc.csc_id AS CHAR)=?) ORDER BY cmp.cmp_comp,cc.csc_name,cc.csc_id LIMIT 50", user.headquartersId(),term,term,term,query==null?"":query.trim()).stream() .map(r -> new CostCenter(id(r,"csc_id"),id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"), - text(r,"csc_name"),nullableId(r,"csc_id_payer"),number(r,"cs_fixprice_discount"),nullableId(r,"tx_id"))).toList(); + text(r,"csc_name"),nullableId(r,"csc_id_payer"),number(r,"cs_discount"),nullableId(r,"tx_id"),id(r,"hq_id"))).toList(); + } + + public CostCenter payer(LegacyUser user,long id) { + user.require(7);var r=billingCenter(user,id,true); + return new CostCenter(id(r,"csc_id"),id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"),text(r,"csc_name"),nullableId(r,"csc_id_payer"),number(r,"cs_discount"),nullableId(r,"tx_id"),id(r,"hq_id")); + } + + Map billingCenter(LegacyUser user,long costCenterId,boolean payer) { + var rows=db.queryForList(COST_CENTER_SELECT+COST_CENTER_FROM+" AND cc.csc_id=?",user.headquartersId(),costCenterId); + if(rows.isEmpty()&&!user.customerAccount())rows=new OrderBillingPayers(db).configured(user,costCenterId); + var result=one(rows,"Kostenstelle ist gesperrt, nicht freigegeben oder nicht als Drittzahler dieser Niederlassung hinterlegt."); + if(payer&&id(result,"csc_is_extern")==1)throw invalid("Eine externe Adresskostenstelle kann nicht als Rechnungszahler verwendet werden."); + return result; } Map costCenter(LegacyUser user,long costCenterId,boolean payer) { @@ -95,14 +112,32 @@ public class OrderEntryCatalog { public CustomerDefaults defaults(LegacyUser user,long costCenterId) { user.require(7); return db.readTransaction(() -> { - var center=costCenter(user,costCenterId,false); + var center=billingCenter(user,costCenterId,false); var mail=db.queryForList("SELECT u.usr_inv_email FROM customer cs JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id WHERE cs.cs_id=?",id(center,"cs_id")); return new CustomerDefaults(mail.isEmpty()?"":text(mail.getFirst(),"usr_inv_email"),text(center,"cmp_stax_idno"), id(center,"cs_jb_commission_no")==1 && enabled(user.headquartersId(),"MASK_COMMISSION_NO_MANDATORY"), - enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(center,"cs_id"))); + enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(center,"cs_id")), automaticPriceMode(user,costCenterId),id(center,"cs_tracking")==1,id(center,"cs_fixprice_discount")==1); }); } + boolean customerAutomaticPriceDisabled(LegacyUser user,long costCenterId,Long pickupCenterId) { + if(!user.customerAccount())return false; + var customer=billingCenter(user,costCenterId,false); + if(pickupCenterId!=null){var pickup=costCenter(user,pickupCenterId,false);if(id(pickup,"cs_jbcond")==1)customer=pickup;} + return CustomerStandardPricing.read(db,this,user,id(customer,"cs_id")).disabled(); + } + + PriceMode automaticPriceMode(LegacyUser user,long costCenterId) { + var center=billingCenter(user,costCenterId,false); + String override=parameter(0,"COSTCENTER_INV_MODE_"+costCenterId); + int mode; + try {mode=override.isBlank()?(int)id(center,"cs_invmode"):Integer.parseInt(override);} + catch(NumberFormatException e){throw invalid("Der Abrechnungsmodus der Kostenstelle ist ungültig.");} + return mode==0?PriceMode.ZONE:PriceMode.TARIFF; + } + + boolean allCouriersAllowed(LegacyUser user){user.require(7);return CallCenterAccess.allCouriers(db,user);} + public List filters(LegacyUser user) { user.require(7); return db.queryForList("SELECT crf_short,crf_longtext,crf_status FROM courierfilter ORDER BY crf_short").stream() @@ -116,7 +151,7 @@ public class OrderEntryCatalog { } public List services(LegacyUser user,long costCenterId) { - user.require(7);var center=costCenter(user,costCenterId,false); + user.require(7);var center=billingCenter(user,costCenterId,false); long cs=id(center,"cs_id"); var selected=db.queryForList("SELECT srv_id FROM customerservice WHERE cs_id=? UNION SELECT srv_id FROM service WHERE cs_id=?",cs,cs); boolean specific=!selected.isEmpty(); @@ -156,9 +191,26 @@ public class OrderEntryCatalog { text(r,"crvh_sid"),text(r,"cmp_comp"),(int)id(r,"vht_id"),text(r,"crvh_vh_sign"))).toList(); } - Map vehicle(LegacyUser user,long vehicleId) { - return one(db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_payload,cv.crvh_env_badge_level,cr.cr_sid AS current_sid "+VEHICLE_FROM+ - " AND cv.crvh_id=?",user.headquartersId(),vehicleId),"Fahrzeug ist nicht verfügbar oder gehört zu einer anderen Niederlassung."); + static String vehicleFrom(boolean crossHeadquarters){return crossHeadquarters?VEHICLE_FROM.replace("cr.hq_id=? AND ",""):VEHICLE_FROM;} + public record CourierParents(boolean enabled,String preferredSid,List vehicles) {} + public CourierParents courierParents(LegacyUser user,int vehicleType) { + user.require(7);user.require(38); + if(vehicleType<0)throw invalid("Ungültiger Fahrzeugtyp."); + return db.readTransaction(()->{ + var policy=OrderCourierParentPolicy.read(this,user.headquartersId()); + if(!policy.enabled())return new CourierParents(false,"",List.of()); + var rows=db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_vh_sign,cmp.cmp_comp "+VEHICLE_FROM+ + " AND (LOCATE(?,cv.crvh_sid)>0 OR LOCATE(?,cv.crvh_sid)>0) ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 101",user.headquartersId(),policy.marker0(),policy.marker1()); + if(rows.size()>100)throw invalid("Zu viele Verrechnungsfahrzeuge. Bitte die Vorgaben prüfen."); + var vehicles=rows.stream().map(r->new Vehicle(id(r,"crvh_id"),id(r,"cr_id"),text(r,"crvh_sid"),text(r,"cmp_comp"),(int)id(r,"vht_id"),text(r,"crvh_vh_sign"))).toList(); + return new CourierParents(true,policy.preferredSid(vehicleType),vehicles); + }); + } + Map vehicle(LegacyUser user,long vehicleId) {return vehicle(user,vehicleId,false);} + Map vehicle(LegacyUser user,long vehicleId,boolean crossHeadquarters) { + if(crossHeadquarters){user.require(4);user.require(7);} + return one(db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_payload,cv.crvh_env_badge_level,cr.cr_sid AS current_sid,cr.hq_id AS courier_hq "+vehicleFrom(crossHeadquarters)+ + " AND cv.crvh_id=?",crossHeadquarters?new Object[]{vehicleId}:new Object[]{user.headquartersId(),vehicleId}),"Fahrzeug ist nicht verfügbar oder gehört zu einer anderen Niederlassung."); } Map headquarters(LegacyUser user) { diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java index 54723c1..c96a67f 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryController.java @@ -18,10 +18,12 @@ public class OrderEntryController { public OrderEntryController(OrderEntryCatalog catalog,OrderEntryService service,OrderPhotoWorkflows photos) {this.catalog=catalog;this.service=service;this.photos=photos;} @GetMapping("/entry/options") public Options options(Authentication auth) {return catalog.options(user(auth));} @GetMapping("/entry/cost-centers") public List costCenters(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.costCenters(user(auth),q);} + @GetMapping("/entry/cost-centers/{id}") public CostCenter payer(Authentication auth,@PathVariable long id) {return catalog.payer(user(auth),id);} @GetMapping("/entry/cost-centers/{id}/addresses") public List
addresses(Authentication auth,@PathVariable long id) {return catalog.addresses(user(auth),id);} @GetMapping("/entry/cost-centers/{id}/photo-workflows") public PhotoOptions photoWorkflows(Authentication auth,@PathVariable long id) {return photos.options(user(auth),id);} @GetMapping("/entry/vehicles") public List vehicles(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.vehicles(user(auth),q);} - @PostMapping("/quote") public Quote quote(Authentication auth,@Valid @RequestBody Request request) {return service.quote(user(auth),request);} + @GetMapping("/entry/courier-parents") public OrderEntryCatalog.CourierParents courierParents(Authentication auth,@RequestParam int vehicleType) {return catalog.courierParents(user(auth),vehicleType);} + @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); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java index 1c07de5..1d1301b 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryExtras.java @@ -22,12 +22,12 @@ final class OrderEntryExtras { void validate(LegacyUser user,Request request,Map payer) { if(id(payer,"cs_jb_commission_no")==1 && catalog.enabled(user.headquartersId(),"MASK_COMMISSION_NO_MANDATORY") && blank(request.reference())) throw invalid("Für diesen Kunden ist eine Auftragsreferenz / Kommissionsnummer erforderlich."); - if(catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(payer,"cs_id")) && request.services().isEmpty()) + if(!user.customerAccount() && catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(payer,"cs_id")) && request.services().isEmpty()) throw invalid("Dieser Kunde benötigt eine Kalkulation mit Leistungspositionen."); 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=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(CourierFilter::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()) @@ -35,8 +35,9 @@ final class OrderEntryExtras { for(Attachment file:stop.attachments())validateAttachment(file); } if(request.insurance()!=null)insuranceCourier(user,request); - if(request.sourceOrderId()!=null)requireOrder(user,request.sourceOrderId()); + if(request.sourceOrderId()!=null){if(user.customerAccount())new CustomerEntryTours(db).require(user,new CustomerEntryAccess(db).state(user),request.sourceOrderId());else OrderEntryOrigins.validateSource(user,request,requireOrder(user,request.sourceOrderId()));} if(request.overwriteTemplateId()!=null && blank(request.tourName()))throw invalid("Bitte einen Namen für die gespeicherte Tour angeben."); + if(request.updateInvoiceEmail()&&id(payer,"hq_id")>0&&id(payer,"hq_id")!=user.headquartersId())throw invalid("Bitte die Rechnungsmail in der Niederlassung des Zahlers pflegen."); if(request.updateInvoiceEmail() && blank(request.invoiceEmail()))throw invalid("Bitte eine Rechnungsmailadresse angeben."); } void globalExclusions(LegacyUser user,Request request,long orderId) { @@ -90,6 +91,7 @@ final class OrderEntryExtras { if(!blank(request.invoiceOnlyText()))metadata(orderId,"jb_hiddenFreetext_1",request.invoiceOnlyText(),""); if(request.origin()==6)metadata(orderId,"jb_origin_other",request.originOther().trim(),""); if(request.sourceOrderId()!=null)metadata(orderId,"ng_source_order",request.sourceOrderId().toString(),""); + if(Boolean.TRUE.equals(request.bwv()))metadata(orderId,"jb_bwv","1",""); if(request.acceptUnverifiedAddresses())metadata(orderId,"ng_address_override","1",Long.toString(user.userId())); String seed=json(request); // TEXT holds at most 64 KiB; full multi-stop drafts can be larger. @@ -157,7 +159,9 @@ final class OrderEntryExtras { } Map requireOrder(LegacyUser user,long orderId) { user.require(7); - return one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",orderId,user.headquartersId()),"Der Auftrag ist in dieser Niederlassung nicht verfügbar."); + var job=new LinkedHashMap<>(one(db.queryForList("SELECT job.*,jb_origin+0 AS ng_origin_code FROM job WHERE jb_id=? AND hq_id=?",orderId,user.headquartersId()),"Der Auftrag ist in dieser Niederlassung nicht verfügbar.")); + // JDBC otherwise treats TINYINT(1) as a Boolean and loses codes 2 through 8. + job.put("jb_origin",job.remove("ng_origin_code"));return job; } String calculatorText(LegacyUser user,Request request,Quote quote) { var options=catalog.services(user,request.payerCostCenterId()); @@ -188,10 +192,10 @@ final class OrderEntryExtras { "jb_taketime",prepared.now(),"jb_finishtime",prepared.now(),"emp_id",user.userId(),"jb_fixprice",premium,"jb_totalprice",premium,"jb_subtotalprice",premium, "jb_mediationarea_id",0,"jb_mediationarea_name","","jb_sales_tax_rate",19,"jb_sales_tax_rate_sign","V", "jb_freetext_1","Gebuchte Transportversicherung zu Auftrag "+parent+" mit "+insurance.goodsValue()+" EUR Warenwert, Tarif "+insurance.rate()+", Prämie "+premium+" EUR", - "jb_tourdata",".|.|DE","jb_createtime",prepared.now(),"jb_id_parent",0,"jb_dispoinfo","HV zu "+parent,"jb_timeunits",2,"jb_booktime",prepared.now(),"jb_origin",8,"jb_orderer",request.orderer())); + "jb_tourdata",".|.|DE","jb_createtime",prepared.now(),"jb_id_parent",0,"jb_dispoinfo","HV zu "+parent,"jb_timeunits",2,"jb_booktime",prepared.now(),"jb_origin",8,"jb_orderer",request.orderer(),"jb_service","0","jb_scrm","","jb_freetext_2","²²²")); long address=db.insert("address",fields("ad_street","Warenwert "+insurance.goodsValue()+" Euro","ad_zipcode",".","ad_city",".","ad_country","DE")); - db.insert("tour",fields("jb_id",id,"ad_id",address,"tr_sort",1,"tr_comp","Transportzusatzversicherung","tr_hsno",".","tr_person","","csc_id",request.payerCostCenterId(),"tr_remark","","tr_status",0,"tr_ware_from_to",0,"tr_mediationarea_id",0)); - db.insert("tourservice",fields("jb_id",id,"csc_id",request.payerCostCenterId(),"tr_sort",0,"srv_id",0,"trs_srv_name","Fixpreis","srvt_id",0,"trs_srvt_name","","trs_price",premium,"trs_discount",0)); + db.insert("tour",fields("jb_id",id,"ad_id",address,"tr_sort",1,"tr_comp","Transportzusatzversicherung","tr_hsno",".","tr_person","","csc_id",request.payerCostCenterId(),"tr_remark","","tr_status",0,"tr_ware_from_to",0,"tr_mediationarea_id",0,"tr_cs_freetext","")); + 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)",id,request.payerCostCenterId(),premium); metadata(parent,"jb_id_insurance",Long.toString(id),insurance.goodsValue()+"|"+insurance.rate()+"|"+(insurance.specialCase()?1:0)); metadata(id,"jb_invtext_dirty","1",prepared.now().toString()); return id; diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryFeaturesController.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryFeaturesController.java index 535e547..13d2547 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryFeaturesController.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryFeaturesController.java @@ -24,7 +24,7 @@ public class OrderEntryFeaturesController { URI uri=URI.create(url); if(!Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null)throw new IllegalArgumentException(); }catch(IllegalArgumentException e){throw invalid("Die Kartenadresse URL_GEOCORDS ist ungültig.");} - return Map.of("mapUrl",url,"bookStock",user.hasRight(14)); + return Map.of("mapUrl",url,"bookStock",user.hasRight(14),"allCouriersAllowed",catalog.allCouriersAllowed(user)); } @PostMapping("/map/locate") public MapPoint locate(Authentication auth,@Valid @RequestBody Station station) { user(auth).require(7);var point=routing.geocode(station);return new MapPoint(java.math.BigDecimal.valueOf(point.latitude()),java.math.BigDecimal.valueOf(point.longitude())); diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryHeadquarters.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryHeadquarters.java index d5f796d..2942863 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryHeadquarters.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryHeadquarters.java @@ -14,30 +14,24 @@ import static de.stadtbote.votian.OrderEntryModels.*; public class OrderEntryHeadquarters { private final DatabaseService db; public OrderEntryHeadquarters(DatabaseService db){this.db=db;} - private record Access(Map employee,List> branches,List> rights) {} + private record Access(Map employee,List> branches,List> rights,boolean callCenter) {} private Access access(LegacyUser user) { user.require(7); - var rows=db.queryForList("SELECT u.hq_id,e.emp_rights,e.emp_headquarters,h.hq_admin FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_id=? AND e.emp_id=? AND u.usr_type=1 AND u.hq_id=?",user.userId(),user.employeeId(),user.originalHeadquartersId()); + var rows=db.queryForList("SELECT u.hq_id,e.emp_rights,e.emp_headquarters,h.hq_admin FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_id=? AND e.emp_id=? AND u.usr_type=1 AND u.hq_id=? AND e.csc_id=0",user.userId(),user.employeeId(),user.originalHeadquartersId()); if(rows.size()!=1)throw denied(); - var branches=db.queryForList("SELECT DISTINCT h.hq_id,h.hq_mnemonic FROM headquarters h JOIN mandatorheadquarters target ON target.hq_id=h.hq_id JOIN mandatorheadquarters origin ON origin.md_id=target.md_id WHERE origin.hq_id=? AND origin.md_id>0 ORDER BY h.hq_mnemonic,h.hq_id",user.originalHeadquartersId()); - return new Access(rows.getFirst(),branches,db.queryForList("SELECT hq_id,rgt_id FROM employeerights WHERE emp_id=?",user.employeeId())); + var branches=db.queryForList("SELECT DISTINCT h.hq_id,h.hq_mnemonic FROM headquarters h JOIN mandatorheadquarters target ON target.hq_id=h.hq_id JOIN mandatorheadquarters origin ON origin.md_id=target.md_id WHERE origin.hq_id=? AND origin.md_id>0 AND COALESCE(h.hq_disabled,0)=0 AND (SELECT COUNT(*) FROM mandatorheadquarters ownMap WHERE ownMap.hq_id=origin.hq_id)=1 AND (SELECT COUNT(*) FROM mandatorheadquarters targetMap WHERE targetMap.hq_id=h.hq_id)=1 ORDER BY h.hq_mnemonic,h.hq_id",user.originalHeadquartersId()); + return new Access(rows.getFirst(),branches,db.queryForList("SELECT hq_id,rgt_id FROM employeerights WHERE emp_id=?",user.employeeId()),CallCenterAccess.enabled(db,user)); } private String rights(LegacyUser user,long target,Access access) { - String rights=text(access.employee(),"emp_rights"); + String rights=CallCenterAccess.effective(user,text(access.employee(),"emp_rights")); if(target==user.originalHeadquartersId())return rights; boolean root=id(access.employee(),"hq_admin")==user.employeeId(); - if(!root) { + if(!root && !access.callCenter()) { boolean listed=Arrays.stream(text(access.employee(),"emp_headquarters").split(",")).anyMatch(s -> s.trim().equals(Long.toString(target))); boolean entry=access.rights().stream().anyMatch(r -> id(r,"hq_id")==target && id(r,"rgt_id")==8); if(!listed || !entry)throw denied(); } - // PHP getRights: foreign-branch call-center access without master right 10 is restricted. - if(rights.length()<=10 || rights.charAt(10)!='1') { - String mask="1110100111000000000000000000000000000000000000000000000000000000000000"; - StringBuilder restricted=new StringBuilder(); - for(int i=0;i id(r,"hq_id")==target && id(r,"rgt_id")==15) && rights.length()>14) rights=rights.substring(0,14)+"0"+rights.substring(15); @@ -68,6 +62,6 @@ class OrderEntryHeadquartersController { private final OrderEntryHeadquarters headquarters; OrderEntryHeadquartersController(OrderEntryHeadquarters headquarters){this.headquarters=headquarters;} @GetMapping("/api/orders/entry/headquarters") Map available(Authentication auth) { - var user=ApiController.user(auth);return Map.of("currentId",user.originalHeadquartersId(),"items",headquarters.available(user)); + var user=ApiController.user(auth);return Map.of("currentId",user.headquartersId(),"items",headquarters.available(user)); } } diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryInventory.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryInventory.java index 2e745f2..12a52dc 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryInventory.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryInventory.java @@ -26,12 +26,14 @@ public class OrderEntryInventory { public List stocks(LegacyUser user,long payerId,long articleId,String query) { user.require(7);user.require(14); return db.readTransaction(() -> { - long customer=id(catalog.costCenter(user,payerId,true),"cs_id");article(user,articleId); + long customer=id(catalog.billingCenter(user,payerId,true),"cs_id");article(user,articleId); var result=new ArrayList(); - var rows=read("SELECT s.*,COALESCE(sa.stkat_itemquantity,0) AS available FROM stock s LEFT JOIN stockarticle sa ON sa.stk_id=s.stk_id AND sa.at_id=? WHERE s.hq_id=? AND s.stk_visible=1 AND s.stk_readonly=0 AND (s.cs_id=0 OR s.cs_id=?) AND (s.stk_name LIKE ? OR s.stk_barcode LIKE ?) ORDER BY s.stk_name,s.stk_id LIMIT 200",articleId,user.headquartersId(),customer,search(query),search(query)); - for(var row:rows) { - if(allowed(user,row,customer))result.add(new StockOption(id(row,"stk_id"),text(row,"stk_name")+" (#"+id(row,"stk_id")+")",id(row,"available"))); - if(result.size()==50)break; + var policy=StockAccess.policy(this::read,user); + for(int offset=0;;offset+=200){ + var rows=read("SELECT s.*,COALESCE(sa.stkat_itemquantity,0) AS available FROM stock s LEFT JOIN stockarticle sa ON sa.stk_id=s.stk_id AND sa.at_id=? WHERE s.hq_id=? AND s.stk_visible=1 AND s.stk_readonly=0 AND (s.cs_id=0 OR s.cs_id=?) AND (s.stk_name LIKE ? OR s.stk_barcode LIKE ?) ORDER BY s.stk_name,s.stk_id LIMIT 200 OFFSET ?",articleId,user.headquartersId(),customer,search(query),search(query),offset); + for(var row:rows){if(allowed(user,row,customer,policy))result.add(new StockOption(id(row,"stk_id"),text(row,"stk_name")+" (#"+id(row,"stk_id")+")",id(row,"available")));if(result.size()==50)return result;} + if(rows.size()<200)break; + if(offset>=49800)throw invalid("Zu viele Lagerorte. Bitte die Suche eingrenzen."); } return result; }); @@ -61,19 +63,11 @@ public class OrderEntryInventory { if(parents.size()!=1)throw invalid("Der übergeordnete Lagerort ist nicht zugänglich.");row=parents.getFirst(); } } - private boolean allowed(LegacyUser user,Map row,long customer) { + private boolean allowed(LegacyUser user,Map row,long customer) {return allowed(user,row,customer,null);} + private boolean allowed(LegacyUser user,Map row,long customer,StockAccess.Policy policy) { var chain=ancestry(user,row); if(chain.stream().anyMatch(r -> id(r,"stk_visible")!=1 || id(r,"stk_readonly")!=0 || (id(r,"cs_id")!=0 && id(r,"cs_id")!=customer)))return false; - var parameters=read("SELECT par_value FROM parameter WHERE emp_id=? AND hq_id IN (0,?) AND par_key='MASK_STK_SUBSTOCK_ACCESS' ORDER BY hq_id DESC",user.employeeId(),user.headquartersId()); - String restriction=parameters.isEmpty()?"":text(parameters.getFirst(),"par_value"); - if(restriction.isBlank())return true; - long root=id(chain.getLast(),"stk_id");boolean restricted=false,accessible=false; - for(String group:restriction.split("\\||--")) { - String[] ids=group.split(",");if(ids.length==0 || !ids[0].trim().equals(Long.toString(root)))continue; - restricted=true; - for(int i=1;i selected.equals(Long.toString(id(r,"stk_id")))))accessible=true;} - } - return !restricted || accessible; + return policy==null?StockAccess.writable(this::read,user,chain):policy.writable(chain); } private Map stock(LegacyUser user,long stockId,long customer) { var row=one(read("SELECT * FROM stock WHERE stk_id=? AND hq_id=?",stockId,user.headquartersId()),"Lagerort ist in dieser Niederlassung nicht verfügbar."); @@ -88,7 +82,7 @@ public class OrderEntryInventory { private List preview(LegacyUser user,Request request) { if(request.articles().isEmpty())return List.of(); user.require(7); - long customer=id(catalog.costCenter(user,request.payerCostCenterId(),true),"cs_id"); + long customer=id(catalog.billingCenter(user,request.payerCostCenterId(),true),"cs_id"); var balances=new HashMap();var occupied=new HashMap();var serialLocations=new HashMap(); var result=new ArrayList(); boolean booking=request.articles().stream().anyMatch(i -> i.booking()!=null); @@ -159,7 +153,7 @@ public class OrderEntryInventory { } // Stable lock order includes parent stocks (capacity) and articles (new serial-number gaps). var stockIds=new TreeSet();var articleIds=new TreeSet(); - long customer=id(catalog.costCenter(user,request.payerCostCenterId(),true),"cs_id"); + long customer=id(catalog.billingCenter(user,request.payerCostCenterId(),true),"cs_id"); for(var item:request.articles()) { if(item.articleId()>0)articleIds.add(item.articleId()); if(item.booking()!=null)for(long id:List.of(item.booking().fromStockId(),item.booking().toStockId()))if(id>0) diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryLimits.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryLimits.java new file mode 100644 index 0000000..bdf86e0 --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryLimits.java @@ -0,0 +1,30 @@ +package de.stadtbote.votian; + +import java.util.List; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; +import static de.stadtbote.votian.OrderEntryModels.Station; + +/** The PHP mask counts legs: MASK_MAXTOUR legs have one additional station. */ +record OrderEntryLimits(int maxStations, int maxStationNoteLength) { + static OrderEntryLimits read(OrderEntryCatalog catalog,long headquarters) { + return new OrderEntryLimits(value(catalog,headquarters,"MASK_MAXTOUR",99,99)+1, + value(catalog,headquarters,"MASK_MAX_TR_REMARK",2000,2000)); + } + private static int value(OrderEntryCatalog catalog,long headquarters,String key,int fallback,int maximum) { + String raw=catalog.parameter(headquarters,key); + if(raw==null||raw.isBlank())return fallback; + if(!raw.matches("[0-9]{1,4}"))throw invalid("Ungültige Erfassungsvorgabe "+key+". Bitte den Niederlassungsbestand prüfen."); + int value=Integer.parseInt(raw); + if(value<1||value>maximum)throw invalid("Die Erfassungsvorgabe "+key+" muss zwischen 1 und "+maximum+" liegen."); + return value; + } + void validate(List stops) { + if(stops.size()>maxStations)throw invalid("Diese Niederlassung erlaubt höchstens "+maxStations+" Transportstationen ("+(maxStations-1)+" Etappen)."); + for(int i=0;imaxStationNoteLength) + throw invalid("Der Hinweis zu Station "+(i+1)+" darf in dieser Niederlassung höchstens "+maxStationNoteLength+" Zeichen enthalten."); + } + static void validateCentral(OrderEntryCatalog catalog,LegacyUser user,List stops) { + // Existing recurring chains and customer entry use their own policy, not this UI mask. + if(user.accountType()==1&&user.userId()>0)read(catalog,user.headquartersId()).validate(stops); + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java new file mode 100644 index 0000000..a4103ad --- /dev/null +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryMail.java @@ -0,0 +1,104 @@ +package de.stadtbote.votian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.stadtbote.votian.database.DatabaseService; +import jakarta.validation.constraints.*; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.util.*; +import static de.stadtbote.votian.LegacyMapping.*; +import static de.stadtbote.votian.OrderEntryModels.*; +import static de.stadtbote.votian.OrderEntryCatalog.invalid; + +/** Recipient policy from current PHP ajaxReqJob; automailer5 consumes the additional addresses. */ +@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 Policy(List automaticRecipients,List events,List requestRecipients, + boolean additionalAllowed,List warnings,String revision) {} + record Plan(List automaticRecipients,List additionalRecipients,List events,List warnings) {} + 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;} + + @GetMapping Policy options(Authentication auth,@RequestParam long payerId,@RequestParam Payment payment,@RequestParam int vehicleTypeId){ + var user=ApiController.user(auth);user.require(7); + return db.readTransaction(()->policy(user,payerId,payment,vehicleTypeId)); + } + Policy policy(LegacyUser user,long payer,Payment payment,int vehicleType) { + user.require(7);if(vehicleType<1||vehicleType>100)throw invalid("Bitte einen gültigen Fahrzeugtyp wählen."); + catalog.billingCenter(user,payer,payment==Payment.INVOICE); + long billing=payer; + if(payment==Payment.CASH){String configured=catalog.parameter(user.headquartersId(),"CSC_ID_PAYER_CASH"); + if(!configured.matches("[1-9][0-9]{0,9}"))throw invalid("Für die Niederlassung ist kein Systembarzahler konfiguriert."); + billing=Long.parseLong(configured);catalog.billingCenter(user,billing,false); + } + var billingRow=customer(user,billing);var actual=billing==payer?billingRow:customer(user,payer); + var warnings=new ArrayList();var events=events(billingRow); + var automatic=events.isEmpty()?List.of():storedRecipients(billingRow,warnings); + String ignore=catalog.parameter(user.headquartersId(),"MASK_IGNORE_JBSTATUSMAIL2CSC"); + boolean special=CallCenterAccess.entrySpecial(db,user),later=OrderEntryModes.read(db,user).laterAccounting(); + // PHP selects the cash customer's flags unless MASK_CASH_PAYER_SELECT retains the actual payer. + var flagRow=payment==Payment.CASH&&!catalog.enabled(user.headquartersId(),"MASK_CASH_PAYER_SELECT")?billingRow:actual; + boolean change=bit(text(flagRow,"cs_jbstatusmail_fields"),3); + List proposed=ask(change,id(actual,"cs_jbstatusmail")==1,ignore,special,later,vehicleType)?storedRecipients(actual,warnings):List.of(); + if(events.isEmpty())automatic=List.of(); + else if(automatic.isEmpty())warnings.add("Für automatische Statusmails fehlt eine gültige Standardadresse. Zusätzliche Empfänger können ausdrücklich angegeben werden."); + var evidence=List.of(user.headquartersId(),payer,billing,vehicleType,billingRow,actual,ignore,special,later,change); + return new Policy(automatic,events,proposed,!events.isEmpty(),List.copyOf(new LinkedHashSet<>(warnings)),LegacyOrderPricing.hash(OrderEntryExtras.json(evidence))); + } + private Map customer(LegacyUser user,long payer){ + var rows=db.queryForList(""" + SELECT cs.cs_id,cs.cs_jbstatusmail,cs.cs_jbstatusmail2,cs.cs_jbstatusmail3,cs.cs_jbstatusmail_fields, + cs.cs_jbstatusmail2csc,u.usr_email,ca.cscad_email, + (u.usr_id IS NOT NULL AND ca.csc_id IS NOT NULL) AS recipient_linked + FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id + LEFT JOIN employee e ON e.emp_id=cs.cs_admin LEFT JOIN user u ON u.usr_id=e.usr_id + LEFT JOIN costcenteraddress ca ON ca.csc_id=cc.csc_id AND ca.adt_id=2 + WHERE cc.csc_id=? + """,payer); + if(rows.size()!=1)throw invalid("Die Mailvorgaben der Kostenstelle fehlen oder sind mehrdeutig."); + return rows.getFirst(); + } + static boolean ask(boolean change,boolean completion,String ignore,boolean special,boolean later,int vehicle){ + boolean ordinary=ignore.equals("0")||special; + boolean exception=!ignore.equals("0")&&(later||vehicle>=7||ignore.equals("2")); + return (change&&ordinary||exception)&&(ordinary||exception&&(completion||change)); + } + private static boolean bit(String value,int index){return value.length()>index&&value.charAt(index)=='1';} + static List events(Map row){var events=new ArrayList(); + if(id(row,"cs_jbstatusmail3")==1)events.add("Erfassung"); + if(id(row,"cs_jbstatusmail2")==1)events.add("Abholung"); + if(id(row,"cs_jbstatusmail")==1)events.add("Erledigung");return List.copyOf(events); + } + static List addresses(String value){ + if(value==null||value.isBlank())return List.of(); + if(value.length()>2000)throw invalid("Höchstens 2000 Zeichen für zusätzliche Statusmail-Empfänger."); + var result=new LinkedHashMap(); + for(String part:value.split("[,;]",-1)){String address=AppointmentNotifications.address(part);result.putIfAbsent(address.toLowerCase(Locale.ROOT),address);} + if(result.size()>20)throw invalid("Höchstens 20 zusätzliche Statusmail-Empfänger."); + return List.copyOf(result.values()); + } + private static List storedRecipients(Map row,List warnings){ + if(id(row,"recipient_linked")!=1){warnings.add("Für den Standardversand fehlen das Kundenkonto oder die Rechnungsadresse.");return List.of();} + if(!Set.of(0L,1L).contains(id(row,"cs_jbstatusmail2csc"))){warnings.add("Die Auswahl des Standard-Mail-Empfängers ist ungültig. Bitte im Kundenstamm prüfen.");return List.of();} + String raw=text(row,id(row,"cs_jbstatusmail2csc")==1?"cscad_email":"usr_email"); + try{return addresses(raw);}catch(org.springframework.web.server.ResponseStatusException failure){warnings.add("Eine hinterlegte Statusmail-Adresse ist ungültig. Bitte im Kundenstamm prüfen.");return List.of();} + } + Plan plan(LegacyUser user,Request request){ + if(request.statusMail()==null)return null; + var policy=policy(user,request.payerCostCenterId(),request.payment(),request.vehicleTypeId()); + 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()); + } + Quote apply(Quote quote,Plan plan){ + if(plan==null)return quote; + ObjectNode tree=JSON.valueToTree(quote);tree.set("statusMail",JSON.valueToTree(plan)); + tree.put("fingerprint",LegacyOrderPricing.hash(quote.fingerprint()+"|"+OrderEntryExtras.json(plan))); + try{return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);} + } +} diff --git a/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java b/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java index 443eaa7..199aeb8 100644 --- a/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java +++ b/backend/src/main/java/de/stadtbote/votian/OrderEntryModels.java @@ -12,7 +12,7 @@ public final class OrderEntryModels { private OrderEntryModels() {} public enum Mode { IMMEDIATE, RESERVATION, RECURRING, RETROSPECTIVE } public enum Payment { INVOICE, CASH } - public enum PriceMode { TARIFF, FIXED, KILOMETER, ZONE, SERVICES } + public enum PriceMode { AUTO, TARIFF, FIXED, KILOMETER, ZONE, SERVICES } public enum LonghaulMode { AUTO, ASAP } public record Station( @Positive Long costCenterId, @@ -25,8 +25,15 @@ public final class OrderEntryModels { @NotBlank @Pattern(regexp="[A-Z]{2}") String country, @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) { + @Positive Long billingPostalId, @Size(max=30) List<@Valid Attachment> attachments, + @Email @Size(max=60) String trackingEmail) { 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)+"]";} } public record Attachment(@NotBlank @Size(max=255) String filename, @NotBlank @Size(max=1500) String viewUrl, @Size(max=200) String remark) {} @@ -66,7 +73,7 @@ public final class OrderEntryModels { @NotNull @Valid Pricing pricing, @Size(max=64) String quoteFingerprint, @NotNull @Pattern(regexp="[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}") String requestId, - @Min(1) @Max(6) int origin, @Size(max=40) String originOther, boolean offer, + @Min(1) @Max(8) int origin, @Size(max=40) String originOther, boolean offer, @DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal toll, @Size(max=4000) String invoiceOnlyText, @Size(max=50) List<@NotBlank @Size(max=20) String> courierFilters, @@ -77,8 +84,9 @@ public final class OrderEntryModels { @Valid Insurance insurance, @Size(max=100) List<@Valid @NotNull ServiceItem> services, @Email @Size(max=254) String invoiceEmail, boolean updateInvoiceEmail, boolean costSplit, @Size(max=20) List<@NotNull @Valid CourierShare> courierShares, - @Size(max=100) List<@NotNull @Valid ArticleItem> articles, @Valid SustainabilityInput sustainability, @Positive Integer photoSet) { + @Size(max=100) List<@NotNull @Valid ArticleItem> articles, @Valid SustainabilityInput sustainability, @Positive Integer photoSet, @Valid OrderEntryMail.Selection statusMail, Boolean bwv) { public Request { + if(Boolean.FALSE.equals(bwv))bwv=null; courierShares=courierShares==null?List.of():List.copyOf(courierShares); articles=articles==null?List.of():List.copyOf(articles); if(origin==0)origin=1; @@ -117,8 +125,10 @@ public final class OrderEntryModels { public record Option(long id, String label) {} public record Tax(long id, String label, BigDecimal rate) {} public record CostCenter(long id, long customerId, String customerNumber, String customerName, - String name, Long defaultPayerId, BigDecimal fixedDiscount, Long taxId) {} - public record CustomerDefaults(String invoiceEmail, String vatId, boolean referenceRequired, boolean calculationRequired) {} + String name, Long defaultPayerId, BigDecimal fixedDiscount, Long taxId, long headquartersId) { + public CostCenter(long id,long customerId,String customerNumber,String customerName,String name,Long defaultPayerId,BigDecimal fixedDiscount,Long taxId){this(id,customerId,customerNumber,customerName,name,defaultPayerId,fixedDiscount,taxId,0);} + } + public record CustomerDefaults(String invoiceEmail, String vatId, boolean referenceRequired, boolean calculationRequired, PriceMode automaticPriceMode,boolean trackingEnabled,boolean fixedDiscountDefault) {} public record CourierFilter(String code, String label, boolean mandatory) {} public record ServiceOption(long serviceId,long typeId,String service,String type) {} public record SavedOrder(long id,String name,String customer,LocalDateTime pickupAt) {} @@ -126,7 +136,7 @@ public final class OrderEntryModels { String postalCode, String city, String country, String contact, String phone, String note) {} public record Vehicle(long id, long courierId, String sid, String name, int vehicleTypeId, String plate) {} public record Options(List