Document legacy workflows and stabilize migrated services

This commit is contained in:
2026-04-02 12:47:14 +02:00
parent a1129565af
commit 9ff4e95e6f
27 changed files with 2492 additions and 482 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(bash:*)"
]
}
}

View File

@@ -106,10 +106,12 @@ Die PHP-Anwendung wird schrittweise in `services` und `vaadin` ueberfuehrt. Dies
- Der Kommunikationsblock ist jetzt als eigener HQ-Workspace portiert: NG konsolidiert `admin/newsticker.php`, `admin/courier_msggrp.php`, `admin/mf_history.php` und die zugehoerigen Legacy-Nebenpfade in einem Bereich fuer Mitteilungen auf `phoenix_group.tickerforum`, Nachrichtengruppen und Kurierzuordnungen auf `messagegroup`/`courier.cr_msggrp` sowie Endgeraetehistorie und Versand-/Antwortlogik auf `phoenix_log.messageforum`
- Die verbliebenen Newsletter-/Legacy-Nebenpfade sind jetzt fachlich abgeschlossen: NG pflegt Newsletter-Opt-in und DSGVO-Status fuer Kunden- und Kurierfirmen direkt im Kommunikationsworkspace auf `company.cmp_newsletter`/`company.cmp_dsgvo`, waehrend nicht mehr belastbar referenzierte Sonderpfade wie `sysadmin/newsletter/*.php`, `tools/auto_response*.php` und generische `admin/metafield_special_cron.php`-Einmalpfade bewusst als Legacy-Archiv dokumentiert statt weiter stillschweigend als offene NG-Workspaces mitzulaufen; die Bewertungsbasis steht in `LEGACY_SIDEPATH_AUDIT.md`
- Der HQ-Operationsblock fuer Karten, Adressen und Suche ist jetzt als eigener NG-Workspace portiert: NG konsolidiert `locating/map.php`, `admin/ad_admin.php`, `admin/nearBySearch.php` und `admin/traveltime.php` in einer Route fuer globale Kartenuebersicht mit HQ-/Kurierpunkten, direkte Pflege von `address` und `phoenix_special.street`, fuzzy Aehnlichkeitssuche fuer Kunden und Kuriere sowie Legacy-kompatible Pflege der `serviceplz`-/`serviceplztraveltime`-Anfahrtszeiten; Rechte und Sichtbarkeit folgen dabei wieder den Legacy-Bits `19`, `2`, `0` und `1`
- Portierte Alt-Crons mit echtem NG-Fachnachfolger laufen jetzt direkt als Spring-Scheduler im Backend: Auth-Session-Cleanup, der Auto-Logout fuer per Autorevoke haengende PDA-Kuriere sowie Mail-/FTP-Versand fuer Abnahmeprotokolle nutzen native Java-Services, Legacy-Parameter und GDC-Statusfelder statt separater Shell-/PHP-Cronstarter
- Rein technische Serverjobs wie `bzip2`, `rsync` und Replikationsmonitoring sowie bewusst archivierte Spezialimporte/Partnerexporte bleiben weiterhin ausserhalb des Spring-Boot-Schedulers, weil sie entweder Infrastruktur statt Fachlogik sind oder laut Audit keinen nativen NG-Nachfolger mehr haben
## Noch offen
- Keine weiteren grossen fachlichen Legacy-Arbeitsbereiche mehr offen; verbleibend sind nur technische Betriebsentscheidungen fuer Alt-Crons, Einmalintegrationen und Archivpfade ausserhalb der produktiven NG-UI.
- Keine weiteren grossen fachlichen Legacy-Arbeitsbereiche mehr offen; verbleibend sind nur noch Infrastruktur-/Archiventscheidungen fuer Serverjobs, Alt-Partnerexporte und Einmalintegrationen ausserhalb der produktiven NG-UI.
## Nächste empfohlene Vertikalschnitte

1215
html/DOKUMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,445 +0,0 @@
# Dokumentation: Niederlassungen der Stadtbote GmbH
## Phoenix-Portal -- Konfiguration und Abweichungen pro Niederlassung
---
## 1. Systemarchitektur
Das Phoenix-Portal ist eine **Multi-Tenant PHP-Anwendung** zur Auftragsannahme und -vermittlung in der Logistikbranche. Jede Niederlassung (Headquarter/HQ) wird durch eine eindeutige `hq_id` identifiziert und teilt sich eine gemeinsame Codebasis mit individuellen Konfigurationsparametern.
### Konfigurationsebenen (Hierarchie)
```
1. Global (hq_id=0, emp_id=0) -- Systemweite Standardwerte
2. Niederlassung (hq_id=N, emp_id=0) -- HQ-spezifische Overrides
3. Mitarbeiter (hq_id=N, emp_id=M) -- Individuelle Einstellungen
```
Die Funktion `getParameterValue($empId, $key, $hqId)` in `include/dbglobal.inc.php` liest Parameter in dieser Reihenfolge: Mitarbeiter -> Niederlassung -> Global.
### Kernkomponenten
| Datei | Funktion |
|---|---|
| `include/dbglobal.inc.php` | Parameter-Verwaltung (`getParameterValue`, `setParameterValue`, `defineGlobalParameters`) |
| `include/auth.inc.php` | Authentifizierung und HQ-Zugriffskontrolle |
| `admin/hq_admin.php` | Admin-Oberflaeche "NIEDERLASSUNGEN" |
| `include/services_func.inc.php` | Preisberechnung pro HQ |
| `include/mcglobal.inc.php` | Datenbankfeld-Definitionen mit HQ-Bezug |
### Datenbanktabellen mit HQ-Bezug
| Tabelle | HQ-Felder | Zweck |
|---|---|---|
| `headquarters` | `hq_id`, `hq_mnemonic`, `hq_name` | Niederlassungsdefinitionen |
| `mandatorheadquarters` | `md_id`, `hq_id` | Zuordnung Mandant zu Niederlassung |
| `parameter` | `par_key`, `hq_id`, `emp_id`, `par_value` | Konfigurationsparameter |
| `job` | `hq_id_exec`, `hq_id_dispo`, `hq_id_sales` | Auftragszuordnung (Ausfuehrung, Disposition, Vertrieb) |
| `customer` | `hq_id` | Kundenzuordnung |
| `courier` | `hq_id` | Kurierzuordnung |
| `tour` | `hq_id_dispo` | Tourenzuordnung |
| `servicehistory` | `hq_id` | Preistabellen pro HQ |
| `serviceplzhistory` | `hq_id` | PLZ-basierte Preise pro HQ |
| `serviceplzareahistory` | `hq_id` | PLZ-Gebiet-Preise pro HQ |
---
## 2. Uebersicht der Niederlassungen
| hq_id | Kuerzel | HQ_INSTANCE | Stadt | MANDATOR_PREFIX | EXPORT_HQ_KEY |
|-------|---------|-------------|-------|-----------------|---------------|
| 0 | -- | (global) | -- | -- | -- |
| 101 | HT_HB | HT_HB | Bremen | HTHB | 003 |
| 102 | HT_HH | HT_HH | Hamburg | HTHH | 001 |
| 103 | HT_B | HT_B | Berlin | HTB | 007 |
| 104 | HT_H | HT_H | Hannover | HTH | 006 |
| 105 | HT_F | HT_F | Frankfurt | HTF | 002 |
| 106 | HT_DD | HT_DD | Dresden | HTDD | 005 |
| 107 | HT_E | HT_E | Essen | HTE | 009 |
| 108 | HT_L | HT_L | Leipzig | HTL | 008 |
| 109 | HT_M | HT_M | Muenchen | HTM | 013 |
| 110 | HT_N | HT_N | Nuernberg | HTN | 023 |
| 111 | HT_S | HT_S | Stuttgart | HTS | 014 |
| 112 | HT_K | HT_K | Koeln | HTK | 015 |
| 203 | HT_LG | HT_LG | Logistics (Zentrale) | HTLG | 010 |
Alle Niederlassungen teilen: `SRV_INSTANCE = HT1`, `PATH_DOCROOT = /home/www/hansetrans`
---
## 3. Globale Einstellungen (fuer alle NL gleich)
### 3.1 System & Sicherheit
| Parameter | Wert | Beschreibung |
|---|---|---|
| `TA_STATUS` | 1 | Transaktionsmodus aktiviert (InnoDB) |
| `AD_STATUS` | 1 | Adressprufung aktiv |
| `MG_STATUS` | 0 | M&G-Server Adressprufung deaktiviert |
| `LOG_DB` | 1 | Datenbank-Logging aktiv |
| `HTTP_VARS_SEC_STATE` | 1 | HTTP-Parameter-Verschluesselung aktiv |
| `HTTP_VARS_SEC_SEQ` | __ | Verschluesselungs-Identifikator |
| `ENCRYPT_EXPORTDATA` | 0 | Export-Datenverschluesselung deaktiviert |
| `ENCRYPT_FILEEXTENSION` | gpg | Verschluesselungs-Dateiendung |
### 3.2 Automailer
| Parameter | Wert | Beschreibung |
|---|---|---|
| `AUTOMAILER_ENABLED` | 1 | Automatischer Mailversand aktiv |
| `AUTOMAILER_STARTTIME_IN_DAYS` | 3 | Mails fuer Auftraege der letzten 3 Tage |
| `AUTOMAILER_LOGFILE` | ../log/automailer.log | Logdatei-Pfad |
| `AUTOMAILER_SLEEP_TIME` | 5 | Wartezeit zwischen Mails (Sekunden) |
### 3.3 Autoranking (Kurierzuweisung)
| Parameter | Wert | Beschreibung |
|---|---|---|
| `AUTORANKING_ASSIGNMENT_ENABLED` | 0 | Automatische Zuweisung deaktiviert |
| `AUTORANKING_REVOCATION_ENABLED` | 1 | Ruecknahme aktiviert |
| `AUTORANKING_REVOKETIME_IN_MINUTES` | 1 | Ruecknahme nach 1 Min. (automat.) |
| `AUTORANKING_REVOKETIME_MANUELL_IN_MINUTES` | 3 | Ruecknahme nach 3 Min. (manuell) |
| `AUTORANKING_MAXNUMBER_OF_CHALLENGES` | 2 | Max. Anfragen pro Kurier |
| `AUTORANKING_NUMBER_OF_ITERATIONS` | 2 | Anzahl Iterationen |
| `AUTORANKING_NEIGHBOUR_LEVEL` | 1 | Nachbargebiete pruefen: 1 Ebene |
| `AUTORANKING_VEHICLE_LKW` | 10 | Fahrzeugtyp ab dem LKW-Jobs vergeben werden |
| `RANKING_CR2CRVH_MULTI_RELATION` | 1 | Kurier-Fahrzeug-Pflichtverknuepfung |
| `RANKING_FAVOURED_COURIER_FOR_PAYER` | 1 | Bevorzugte Kuriere fuer Zahler pruefen |
| `RANKING_FAVOURED_COURIER_FOR_STATION` | 0 | Bevorzugte Kuriere fuer Stationen nicht pruefen |
| `RANKING_FAVOURED_COURIER_AREA_RESTRICTION` | 0 | Keine Gebietseinschraenkung fuer bevorzugte Kuriere |
### 3.4 Geo & Lokalisierung
| Parameter | Wert | Beschreibung |
|---|---|---|
| `GEO_EARTH_RADIUS` | 6371.0 | Erdradius in km (WGS84 Mittelwert) |
| `LOCATING_MODE` | 0 | Polygon-Modus (Standard) |
| `MAXIMUM_SEARCH_RADIUS` | 20 | Suchradius in km |
| `LOCATING_LBS_SERVER` | 139.7.25.166 | LBS-Server IP |
### 3.5 Auftragserfassung (UI)
| Parameter | Wert | Beschreibung |
|---|---|---|
| `MASK_CALCULATOR` | 1 | Preisrechner aktiviert |
| `MASK_CALCULATOR_SRV` | 1 | Services im Rechner waehlbar |
| `MASK_MANUAL_DISPOSITION` | 1 | Manuelle Disposition als Standard |
| `MASK_COMMISSION_NO` | 1 | Kommissionsnummer immer aktiviert |
| `MASK_CASH_PAYER_SELECT` | 1 | Barzahler muss gewaehlt werden |
| `MASK_ASK_DEFAULTPAYER_CHANGE` | 1 | Nachfrage bei Zahleraenderung |
| `MASK_COURIERDETAILS_TARGET` | 1 | Kurierdetails in separatem Fenster |
| `MASK_CUSTOMERDETAILS_TARGET` | 1 | Kundendetails in separatem Fenster |
| `MASK_LOCKTIME_TIMEOUT` | 5 | Sperr-Timeout: 5 Minuten |
| `MASK_JOBLIST_BROWSE_MAX` | 100 | Max. 100 Zeilen in Auftragsliste |
| `MASK_JOBLIST_DEFAULTLIST` | 8,9,0,1 | Standard-Auftragslisten: Vermittlung, Abgeholt, Offen, Zugewiesen |
| `MASK_DATE_PLUSOFFSETDAYS` | 2 | Datumsoffset: 2 Tage voraus |
| `MASK_COURIER_FREETIME_MINUTES` | 30 | Kurier als frei: 30 Min. vor Auftragszeit |
| `MASK_COURIER_NEWBIE_TIME` | 30 | Kurier als Neuling markiert: 30 Tage |
| `LATEST_TAKETIME_IN_MINUTES` | 30 | Standard-Uebernahmezeit: 30 Min. |
### 3.6 Rechnungswesen
| Parameter | Wert | Beschreibung |
|---|---|---|
| `INV_MAXCOLS` | 50 | Max. Spalten Rechnungstext (Anzeige) |
| `INV_MAXCOLS_EXPORT` | 70 | Max. Spalten Rechnungstext (Export) |
| `INV_JB_CR_PRICE` | 1 | Fuhrlohn in Rechnungsmodul anzeigen |
| `INV_JB_INVOICE_CR` | 1 | Separate Kurierrechnung generieren |
| `INV_PRINT_DISCOUNT` | 1 | Rabatt auf Rechnung drucken |
| `INV_PRINT_REMARK` | 0 | Bemerkung nicht auf Rechnung drucken |
| `JB_PAYMODE_CASH` | BZ | Barzahlung-Kuerzel |
| `JB_TAX_RATE_SIGN` | OM | Standard-Steuersatz-Kennzeichen |
### 3.7 FTP & Export-Pfade
| Parameter | Wert | Beschreibung |
|---|---|---|
| `FTP_SERVER` | 172.16.0.104 | Interner FTP-Server |
| `FTP_USER` | sap | FTP-Benutzer |
| `FTP_UPLOADPATH` | /stadtbote/ | Upload-Verzeichnis |
| `EXPORT_PATH` | ../export/download/ | Export-Dateipfad |
| `EXPORT_FILES_ON_SERVER` | 100 | Max. Export-Dateien (HQ) |
| `EXPORT_FILES_ON_SERVER_CUSTOMER` | 10 | Max. Export-Dateien (Kunde) |
### 3.8 Sonstige globale Einstellungen
| Parameter | Wert | Beschreibung |
|---|---|---|
| `COUNTRY_FON_PREFIX` | 49 | Laendervorwahl Deutschland |
| `ZIPCODE_LENGTH` | 5 | PLZ-Laenge |
| `ZIPCODEAREA_PADLENGTH` | 4 | PLZ-Gebiet-Laenge (z.B. "0057") |
| `MODE_INTERMEDIATION` | 2 | Vermittlungsmodus: PLZ-Gebiet |
| `MESSAGE_MAX_BODY_LENGTH` | 200 | Max. Nachrichtenlaenge |
| `MASK_MARKUP_MODE` | 2 | Kraftstoffzuschlag-Modus: HT |
| `MANDATOR_SERVICE_ENABLED` | 1 | Service-Modul aktiviert |
| `MANDATOR_SERVICE2_ENABLED` | 1 | Service2-Modul aktiviert |
| `MD_GLOBAL_SHORTNAME` | HTG | Globales Mandantenkuerzel |
| `MASTER_PREFIX` | HT | Master-Praefix |
| `FRAMEWORK_USED` | 1 | Framework aktiviert |
---
## 4. Abweichungen pro Niederlassung
### 4.1 Kostenstellen (CSC_ID_PAYER)
| NL | Stadt | CSC_ID_PAYER_CASH | CSC_ID_PAYER_EXTERN | CSC_ID_PAYER_CALCULATOR |
|----|-------|-------------------|---------------------|------------------------|
| 101 | Bremen | 5688 | 44539 | individuell |
| 102 | Hamburg | 7797 | 44540 | individuell |
| 103 | Berlin | 6758 | 44541 | individuell |
| 104 | Hannover | 7402 | 44542 | individuell |
| 105 | Frankfurt | 9286 | 44543 | individuell |
| 106 | Dresden | 6533 | 44544 | individuell |
| 107 | Essen | 9374 | 44545 | individuell |
| 108 | Leipzig | 9117 | 44546 | individuell |
| 109 | Muenchen | 9750 | 44547 | individuell |
| 110 | Nuernberg | 10041 | 44548 | individuell |
| 111 | Stuttgart | 10142 | 44549 | individuell |
| 112 | Koeln | 55240 | 55241 | individuell |
| 203 | Logistics | 6758* | 44541* | individuell |
*) LG teilt Kostenstellen mit Berlin
### 4.2 EID-Nummernkreise (Kunden/Kuriere/Artikel)
| NL | Stadt | CS_EID_GENERATION | CR_EID_GENERATION | CS_EID_PREFIX | CR_EID_PREFIX | AT_EID_PREFIX |
|----|-------|-------------------|-------------------|---------------|---------------|---------------|
| 101 | Bremen | HTHB64999 | 28800 | HB | HB | HB |
| 102 | Hamburg | HTHH64999 | 28800 | HH | HH | HH |
| 103 | Berlin | HTB64999 | 28800 | B | B | B |
| 104 | Hannover | HTH64999 | 28800 | H | H | H |
| 105 | Frankfurt | HTF64999 | 28800 | F | F | F |
| 106 | Dresden | HTDD64999 | 28800 | DD | DD | DD |
| 107 | Essen | HTE64999 | 28800 | E | E | E |
| 108 | Leipzig | HTL64999 | 28800 | L | L | L |
| 109 | Muenchen | HTM64999 | 28800 | M | M | M |
| 110 | Nuernberg | HTN64999 | 28800 | N | N | N |
| 111 | Stuttgart | HTS64999 | 28800 | S (aber ESL*) | S (aber ES*) | S (aber ES*) |
| **112** | **Koeln** | **HTK79999** | **HTK89999** | HTK | HTK | HTK |
| 203 | Logistics | HTLG64999 | 28800 | LG | LG | LG |
**Abweichung Koeln (112):** Abweichende Nummernkreise (79999 statt 64999, HTK89999 statt 28800). Koeln verwendet einen hoeheren Bereich und alphanumerische Praefixe in der Kurier-EID-Generierung.
### 4.3 E-Mail-Konfiguration
| NL | MAIL_SENDER_ADDRESS | MAIL_BCC_ADDRESS | MAIL_SALUTATION_TEXT |
|----|---------------------|------------------|----------------------|
| 101 | hb.transport@hansetrans.de | hb.transport@hansetrans.de | Ihre HANSETRANS |
| 102 | hh.transport@hansetrans.de | hh.transport@hansetrans.de | Ihre HANSETRANS |
| 103 | b.transport@hansetrans.de | b.transport@hansetrans.de | Ihre HANSETRANS |
| 104 | h.transport@hansetrans.de | h.transport@hansetrans.de | Ihre HANSETRANS |
| 105 | f.transport@hansetrans.de | f.transport@hansetrans.de | Ihre HANSETRANS |
| 106 | dd.transport@hansetrans.de | dd.transport@hansetrans.de | Ihre HANSETRANS |
| 107 | e.transport@hansetrans.de | e.transport@hansetrans.de | Ihre HANSETRANS |
| **108** | **HANSETRANS Leipzig <l.transport@hansetrans.de>** | l.transport@hansetrans.de | Ihre HANSETRANS |
| 109 | m.transport@hansetrans.de | m.transport@hansetrans.de | Ihre HANSETRANS |
| 110 | n.transport@hansetrans.de | n.transport@hansetrans.de | Ihre HANSETRANS |
| 111 | info@es-l.eu* | (leer) | Ihre ES-Logistic* |
| 112 | k.transport@hansetrans.de | k.transport@hansetrans.de | Ihre HANSETRANS |
| 203 | fnl.transport@hansetrans.de | fnl.transport@hansetrans.de | Ihre HANSETRANS |
**Abweichung Leipzig (108):** Verwendet Absendername mit Klammern-Format `HANSETRANS Leipzig <...>` statt nur E-Mail-Adresse.
**Abweichung Stuttgart (111):** Laeuft unter der Marke **ES-Logistic** (eigene Domain `es-l.eu`) statt Hansetrans.
### 4.4 PDA-Lokalisierung
| NL | LOCATING_PDA_ENABLED | LOCATING_PDA_INTERVAL |
|----|---------------------|-----------------------|
| 101 Bremen | 1 (aktiv) | **8,0,9,0** (nur 08:00-09:00) |
| 102 Hamburg | 1 (aktiv) | **0,0,12,13** (00:00-12:13) |
| 103-107, 109-112 | 1 (aktiv) | 0,0,23,59 (ganztaegig) |
| **108 Leipzig** | **0 (deaktiviert)** | 0,0,23,59 |
| 203 Logistics | 0 (deaktiviert) | 0,0,23,59 |
**Abweichungen:**
- **Bremen:** Extrem eingeschraenktes Lokalisierungsfenster (nur 1 Stunde morgens)
- **Hamburg:** Halbtaegiges Fenster bis mittags
- **Leipzig & Logistics:** PDA-Lokalisierung komplett deaktiviert
### 4.5 Max. Stationen pro Tour (MASK_MAXTOUR)
| NL | Wert |
|----|------|
| **102 Hamburg** | **49** |
| **105 Frankfurt** | **49** |
| **109 Muenchen** | **49** |
| Alle anderen | 19 |
Hamburg, Frankfurt und Muenchen erlauben wesentlich mehr Stationen pro Auftrag.
### 4.6 Kundenprovision
| Parameter | Bremen (101) | Hannover (104) | Alle anderen |
|---|---|---|---|
| `JB_EDITBATCH_CS_PROV_ENABLED` | **1** (aktiv) | **1** (aktiv) | 0 (deaktiviert) |
| `MASK_CS_PROV_DEFAULT` | **14.00%** | **10.0%** | 0% |
Nur Bremen und Hannover haben die Kundenprovisionsberechnung aktiviert.
### 4.7 Rechnungseinstellungen (Abweichungen)
| Parameter | Beschreibung | Abweichende NL | Wert | Standard |
|---|---|---|---|---|
| `INV_JB_CR_PRICE_STATIONS` | Stationen im Kurier-Rechnungsmodul | **101 Bremen, 103 Berlin** | **1** (aktiv) | 0 |
| `INV_SHOW_INVOICE_TEXT` | Rechnungstext fuer Kuriere sichtbar | **101 Bremen, 106 Dresden, 108 Leipzig** | **0** (deaktiviert) | 1 |
### 4.8 Auftragsverhalten (Abweichungen)
| Parameter | Beschreibung | Abweichende NL | Wert | Standard |
|---|---|---|---|---|
| `MODE_COPY_JOB_DISPOINFO` | Dispoinfo beim Kopieren ignorieren | **101 Bremen** | **1** | 0 |
| `MASK_JB_TYPE_DEFAULT` | Standard-Auftragstyp | alle NL | 2 (Guetertaxi) | -- |
| `MASK_INVOICE_SIDS_SID` | Spezial-SID fuer Multi-SID | **111 Stuttgart** | **S888** | {Stadt}1888 |
### 4.9 Mail-Footer (Adresse & Kontakt)
Jede Niederlassung hat individuelle Postadresse und Telefonnummern im Mail-Footer. Beispiele:
| NL | Adresse | Telefon |
|----|---------|---------|
| 101 Bremen | Hansetrans Guetertaxi GmbH, Am Wall 175, 28195 Bremen | 0421 39 39 39 |
| 102 Hamburg | Hansetrans Guetertaxi GmbH, Nagelsweg 10, 20097 Hamburg | 040 41 41 41 |
| 103 Berlin | Hansetrans Guetertaxi GmbH, Motzener Str. 6, 12277 Berlin | 030 75 75 75 |
| 111 Stuttgart | ES-Logistic GmbH, Ulmer Strasse 53/1, 73262 Reichenbach | 07153 99 67 353 |
### 4.10 Cron-Benachrichtigungen
Alle Niederlassungen senden Cron-Mails an `mail-cron@assecutor.de`, ergaenzt um standortspezifische Adressen:
- `kennziffer95-{stadt}@hansetrans.de`
- `eg-lizenz-{stadt}@hansetrans.de`
- `vorortpruefung-{stadt}@hansetrans.de`
- `unternehmerbefragung-{stadt}@hansetrans.de`
---
## 5. Niederlassungs-spezifische Dateien (Code-Ebene)
### 5.1 Cron-Jobs
| Datei | Niederlassung(en) | Funktion |
|---|---|---|
| `tools/cron_export_HTG.php` | HTG (alle NL) | RETRANS-Export mit Mapping aller 12 NL-IDs |
| `sysadmin/cron/cron_export_HTG.php` | HTG | Haupt-Export-Cron |
| `sysadmin/cron/cron_export_HTM.php` | HTM (109 Muenchen) | Moebel/Kuechen-Export |
| `export/cron_export_HHA.php` | HHA | Hamburg-spezifischer Export |
| `tools/cron_export_SB_MC.php` | MC | Maritim-spezifischer Export |
| `tools/cron_FTP_upload_HTG.php` | HTG | FTP-Upload fuer alle NL |
### 5.2 Spezialmodule
| Datei | Niederlassung | Funktion |
|---|---|---|
| `admin/jb_list_MC.php` | MC | Spezielle Auftragsliste |
| `statistic/statistic_MC.php` | MC | Eigenes Statistikmodul |
| `statistic/statistic_interface_cs_MC.inc.php` | MC | Kundenstatistik-Interface |
| `statistic/statistic_interface_hq_MC.inc.php` | MC | NL-Statistik-Interface |
| `include/jb_list_defineoutput_MC.inc.php` | MC | Spezielle Listenausgabe |
| `tools/auto_response_MC.php` | MC | Spezielle Auto-Antwort |
| `tools/statistic_special_GFL_MC.php` | MC | GFL-Spezialstatistik |
| `tools/statistic_HTM_01.php` | HTM | Kuechen-Montage-Statistik |
### 5.3 Dynamisch geladene Module
| Muster | Lademechanismus | Beispiele |
|---|---|---|
| `import/import_*.php` | `data_transfer.php` via `$f_ftp_servername` | import_HHA.php, import_FAMO.php, import_BMW.php, etc. |
| `tools/auto_response_*.php` | `auto_response.php` via Kundenkennung | auto_response_MC.php, etc. |
| `tools/auto_export_*.php` | `auto_export.php` | auto_export_stkat_DKT.php, etc. |
---
## 6. HTG Sub-Niederlassungen (Cron-Export-Mapping)
Die Datei `tools/cron_export_HTG.php` definiert das Mapping aller HTG-Niederlassungen:
| hq_id | Kuerzel | Stadt | FTP-Endpunkt |
|-------|---------|-------|-------------|
| 101 | HB | Bremen | eigener FTP-Pfad |
| 102 | HH | Hamburg | eigener FTP-Pfad |
| 103 | B | Berlin | eigener FTP-Pfad |
| 104 | H | Hannover | eigener FTP-Pfad |
| 105 | F | Frankfurt | eigener FTP-Pfad |
| 106 | DD | Dresden | eigener FTP-Pfad |
| 107 | E | Essen | eigener FTP-Pfad |
| 108 | L | Leipzig | eigener FTP-Pfad |
| 109 | M | Muenchen | eigener FTP-Pfad |
| 110 | N | Nuernberg | eigener FTP-Pfad |
| 111 | S | Stuttgart | eigener FTP-Pfad |
| 112 | K | Koeln | eigener FTP-Pfad |
| 203 | LG | Logistics | eigener FTP-Pfad |
---
## 7. Besondere Niederlassungen
### 7.1 Stuttgart (hq_id=111) -- Eigenstaendige Marke
Stuttgart operiert unter der Marke **ES-Logistic GmbH** mit:
- Eigener Domain: `es-l.eu`
- Eigenem Logo: `esl_logo.png` (342x93 px)
- Eigener Absenderadresse: `info@es-l.eu`
- Eigenem Mail-Footer mit Reichenbach-Adresse
- Abweichende EID-Praefixe in SQL-Template: `ES` statt Stadtkuerzel
### 7.2 Koeln (hq_id=112) -- Abweichende Nummernkreise
- `CR_EID_GENERATION = HTK89999` (alle anderen: 28800)
- `CS_EID_GENERATION = HTK79999` (alle anderen: {Prefix}64999)
- `CSC_ID_PAYER_EXTERN = 55241` (alle anderen: 44539-44549)
### 7.3 Leipzig (hq_id=108) -- Eingeschraenkte Funktionen
- PDA-Lokalisierung deaktiviert
- Rechnungstext fuer Kuriere nicht sichtbar
- Absender-Mail mit Display-Name-Format
### 7.4 Logistics/Zentrale (hq_id=203) -- Shared mit Berlin
- Teilt Kostenstellen mit Berlin (CSC_ID_PAYER_CASH, CSC_ID_PAYER_EXTERN)
- PDA-Lokalisierung deaktiviert
- Dient als zentrale Verwaltungs-/Logistikeinheit
### 7.5 Bremen (hq_id=101) -- Meiste Sonderkonfiguration
- Kundenprovision aktiv (14%)
- MODE_COPY_JOB_DISPOINFO aktiviert (einzige NL)
- Stationen im Kurier-Rechnungsmodul aktiv
- Rechnungstext fuer Kuriere nicht sichtbar
- Stark eingeschraenktes PDA-Fenster (nur 08:00-09:00)
- Eigene Marketingtexte (MAIL_TEXT_COMPLETION_2/3/4)
---
## 8. Mehrfach-Niederlassungszugriff
Mitarbeiter koennen ueber den Parameter `HEADQUARTERS_MULTIPLE_ACCESS_EMPLOYEES` Zugriff auf mehrere Niederlassungen erhalten. Die Konfiguration erfolgt als Pipe-getrennte Liste von Mitarbeiter-IDs:
```
empId1|empId2|empId3
```
Die Pruefung erfolgt in `include/auth.inc.php` (Zeile 236).
---
## 9. Zusammenfassung der Abweichungen
### Matrix: Funktionsunterschiede
| Funktion | HB | HH | B | H | F | DD | E | L | M | N | S | K | LG |
|----------|----|----|---|---|---|----|---|---|---|---|---|---|----|
| PDA-Lokalisierung | eingeschraenkt | eingeschraenkt | voll | voll | voll | voll | voll | **AUS** | voll | voll | voll | voll | **AUS** |
| Max. Tourstationen | 19 | **49** | 19 | 19 | **49** | 19 | 19 | 19 | **49** | 19 | 19 | 19 | 19 |
| Kundenprovision | **14%** | - | - | **10%** | - | - | - | - | - | - | - | - | - |
| Kurier-Rechnung Stationen | **ja** | - | **ja** | - | - | - | - | - | - | - | - | - | - |
| Kurier sieht Rechnungstext | **nein** | ja | ja | ja | ja | **nein** | ja | **nein** | ja | ja | ja | ja | ja |
| Dispoinfo beim Kopieren | **ignoriert** | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert | kopiert |
| Eigene Marke | HT | HT | HT | HT | HT | HT | HT | HT | HT | HT | **ESL** | HT | HT |
| EID-Nummernkreis | Standard | Standard | Standard | Standard | Standard | Standard | Standard | Standard | Standard | Standard | Standard | **abweichend** | Standard |
---
*Dokumentation erstellt am 24.03.2026 auf Basis der Codebasis und SQL-Dumps im Verzeichnis `/Users/svencarstensen/Downloads/html`*

View File

@@ -42,6 +42,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.11.1</version>
</dependency>
<dependency>
<groupId>com.github.mwiede</groupId>
<artifactId>jsch</artifactId>
<version>0.2.20</version>
</dependency>
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>

View File

@@ -0,0 +1,98 @@
package de.votian.services.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "votian.scheduling")
public class LegacySchedulingProperties {
private String zone = "Europe/Berlin";
private boolean enabled = true;
private String authSessionCleanupCron = "0 */5 * * * *";
private String courierAutoLogoutCron = "0 */30 * * * *";
private long courierAutoLogoutHeadquartersId = 3L;
private String acceptanceProtocolMailCron = "0 */5 * * * *";
private String acceptanceProtocolLetterCron = "30 */5 * * * *";
private int acceptanceProtocolMailLookbackDays = 1;
private int acceptanceProtocolLetterLookbackDays = 3;
private String acceptanceProtocolLetterFtpProfile = "MPS1";
public String getZone() {
return zone;
}
public void setZone(String zone) {
this.zone = zone;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getAuthSessionCleanupCron() {
return authSessionCleanupCron;
}
public void setAuthSessionCleanupCron(String authSessionCleanupCron) {
this.authSessionCleanupCron = authSessionCleanupCron;
}
public String getCourierAutoLogoutCron() {
return courierAutoLogoutCron;
}
public void setCourierAutoLogoutCron(String courierAutoLogoutCron) {
this.courierAutoLogoutCron = courierAutoLogoutCron;
}
public long getCourierAutoLogoutHeadquartersId() {
return courierAutoLogoutHeadquartersId;
}
public void setCourierAutoLogoutHeadquartersId(long courierAutoLogoutHeadquartersId) {
this.courierAutoLogoutHeadquartersId = courierAutoLogoutHeadquartersId;
}
public String getAcceptanceProtocolMailCron() {
return acceptanceProtocolMailCron;
}
public void setAcceptanceProtocolMailCron(String acceptanceProtocolMailCron) {
this.acceptanceProtocolMailCron = acceptanceProtocolMailCron;
}
public String getAcceptanceProtocolLetterCron() {
return acceptanceProtocolLetterCron;
}
public void setAcceptanceProtocolLetterCron(String acceptanceProtocolLetterCron) {
this.acceptanceProtocolLetterCron = acceptanceProtocolLetterCron;
}
public int getAcceptanceProtocolMailLookbackDays() {
return acceptanceProtocolMailLookbackDays;
}
public void setAcceptanceProtocolMailLookbackDays(int acceptanceProtocolMailLookbackDays) {
this.acceptanceProtocolMailLookbackDays = acceptanceProtocolMailLookbackDays;
}
public int getAcceptanceProtocolLetterLookbackDays() {
return acceptanceProtocolLetterLookbackDays;
}
public void setAcceptanceProtocolLetterLookbackDays(int acceptanceProtocolLetterLookbackDays) {
this.acceptanceProtocolLetterLookbackDays = acceptanceProtocolLetterLookbackDays;
}
public String getAcceptanceProtocolLetterFtpProfile() {
return acceptanceProtocolLetterFtpProfile;
}
public void setAcceptanceProtocolLetterFtpProfile(String acceptanceProtocolLetterFtpProfile) {
this.acceptanceProtocolLetterFtpProfile = acceptanceProtocolLetterFtpProfile;
}
}

View File

@@ -0,0 +1,11 @@
package de.votian.services.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
@EnableConfigurationProperties(LegacySchedulingProperties.class)
public class SchedulingConfig {
}

View File

@@ -242,6 +242,32 @@ public class JobController {
return ResponseEntity.ok(jobService.findTodaysJobs());
}
@GetMapping("/today/count")
public ResponseEntity<Map<String, Long>> countTodaysJobs(Authentication authentication) {
UserSessionDto user = sessionUser(authentication);
if (isCourierUser(user)) {
long count = jobService.findByCourierId(user.getCourierId()).stream()
.filter(job -> matchesDateRange(job.getOrderTime(),
java.time.LocalDate.now().atStartOfDay(),
java.time.LocalDate.now().atTime(23, 59, 59)))
.count();
return ResponseEntity.ok(Map.of("count", count));
}
if (!accessControlService.canViewJobs(user)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (isCustomerUser(user)) {
long count = jobService.findByPayerCostCenters(user.getAccessibleCostCenterIds()).stream()
.filter(job -> matchesDateRange(job.getOrderTime(),
java.time.LocalDate.now().atStartOfDay(),
java.time.LocalDate.now().atTime(23, 59, 59)))
.filter(job -> user.getHeadquartersId() == null || user.getHeadquartersId().equals(job.getHeadquartersId()))
.count();
return ResponseEntity.ok(Map.of("count", count));
}
return ResponseEntity.ok(Map.of("count", jobService.countTodaysJobs(user != null ? user.getHeadquartersId() : null)));
}
@PostMapping
public ResponseEntity<Job> create(@RequestBody JobCreateRequest request, Authentication authentication) {
UserSessionDto user = sessionUser(authentication);

View File

@@ -69,9 +69,11 @@ public class Company {
private String logo;
@Column(name = "cmp_logo_width")
@Convert(converter = LegacyBlankableIntegerStringConverter.class)
private Integer logoWidth;
@Column(name = "cmp_logo_height")
@Convert(converter = LegacyBlankableIntegerStringConverter.class)
private Integer logoHeight;
@Column(name = "cmp_modify_status")

View File

@@ -23,18 +23,12 @@ public class CostCenter {
@Column(name = "csc_path")
private String path;
@Column(name = "csc_id_payer")
private Long payerId;
@Column(name = "csc_visible")
private String visible;
@Column(name = "csc_is_extern")
private String isExtern;
@Column(name = "emp_id_related")
private Long relatedEmployeeId;
public CostCenter() {}
public Long getId() { return id; }
@@ -47,12 +41,8 @@ public class CostCenter {
public void setParentId(Long parentId) { this.parentId = parentId; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public Long getPayerId() { return payerId; }
public void setPayerId(Long payerId) { this.payerId = payerId; }
public String getVisible() { return visible; }
public void setVisible(String visible) { this.visible = visible; }
public String getIsExtern() { return isExtern; }
public void setIsExtern(String isExtern) { this.isExtern = isExtern; }
public Long getRelatedEmployeeId() { return relatedEmployeeId; }
public void setRelatedEmployeeId(Long relatedEmployeeId) { this.relatedEmployeeId = relatedEmployeeId; }
}

View File

@@ -1,6 +1,7 @@
package de.votian.services.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.Formula;
@Entity
@Table(name = "employee")
@@ -14,7 +15,7 @@ public class Employee {
@Column(name = "usr_id")
private Long userId;
@Column(name = "hq_id")
@Formula("(select u.hq_id from user u where u.usr_id = usr_id)")
private Long headquartersId;
@Column(name = "csc_id")

View File

@@ -10,7 +10,7 @@ import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "appointment", schema = "phoenix_group")
@Table(name = "appointment", catalog = "phoenix_group")
public class GroupwareAppointment {
@Id

View File

@@ -149,6 +149,7 @@ public class Job {
private Integer longhaul;
@Column(name = "jb_service")
@Convert(converter = LegacyBlankableIntegerStringConverter.class)
private Integer service;
@Column(name = "vht_id")

View File

@@ -0,0 +1,25 @@
package de.votian.services.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter
public class LegacyBlankableIntegerStringConverter implements AttributeConverter<Integer, String> {
@Override
public String convertToDatabaseColumn(Integer attribute) {
return attribute != null ? attribute.toString() : null;
}
@Override
public Integer convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
String normalized = dbData.trim();
if (normalized.isEmpty()) {
return null;
}
return Integer.valueOf(normalized);
}
}

View File

@@ -18,6 +18,15 @@ public interface CostCenterRepository extends JpaRepository<CostCenter, Long> {
@Query("SELECT csc FROM CostCenter csc WHERE csc.customerId = :csId AND csc.visible = '1'")
List<CostCenter> findVisibleByCustomerId(@Param("csId") Long csId);
@Query(value = "SELECT c.* " +
"FROM costcenter c " +
"JOIN customer cs ON cs.cs_id = c.cs_id " +
"WHERE cs.hq_id = :hqId " +
" AND c.csc_visible = 1 " +
" AND COALESCE(c.csc_is_extern, 0) <> 1",
nativeQuery = true)
List<CostCenter> findVisibleNonExternByHeadquartersId(@Param("hqId") Long hqId);
@Query("SELECT csc FROM CostCenter csc WHERE csc.path LIKE %:pathFragment% AND csc.customerId = :csId")
List<CostCenter> findByPathContainingAndCustomerId(@Param("pathFragment") String pathFragment, @Param("csId") Long csId);
}

View File

@@ -35,6 +35,11 @@ public interface JobRepository extends JpaRepository<Job, Long> {
@Query("SELECT j FROM Job j WHERE j.orderTime >= CURRENT_DATE")
List<Job> findTodaysJobs();
@Query("SELECT COUNT(j) FROM Job j WHERE j.headquartersId = :hqId AND j.orderTime >= :from AND j.orderTime < :to")
long countByOrderTimeBetweenAndHq(@Param("hqId") Long hqId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT j FROM Job j WHERE j.copyPermanentId IS NOT NULL AND j.orderTime > :since " +
"AND (j.costCenterPayerId IN :cscIds OR j.costCenterPayerCashId IN :cscIds) ORDER BY j.id")
List<Job> findPermanentCopies(@Param("since") LocalDateTime since, @Param("cscIds") List<Long> cscIds);

View File

@@ -36,10 +36,11 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByTypeAndHeadquartersIdOrderByNameAscFirstnameAsc(@Param("type") Integer type,
@Param("hqId") Long headquartersId);
@Query("SELECT DISTINCT hq.mnemonic, u.name, u.firstname, u.type " +
"FROM User u JOIN Headquarters hq ON u.headquartersId = hq.id " +
"WHERE FUNCTION('RIGHT', u.birthdate, 5) = FUNCTION('RIGHT', CURRENT_DATE, 5) " +
"ORDER BY u.type, u.name")
@Query(value = "SELECT DISTINCT hq.hq_mnemonic, u.usr_name, u.usr_firstname, u.usr_type " +
"FROM user u JOIN headquarters hq ON u.hq_id = hq.hq_id " +
"WHERE DATE_FORMAT(u.usr_birthdate, '%m-%d') = DATE_FORMAT(CURRENT_DATE, '%m-%d') " +
"ORDER BY u.usr_type, u.usr_name",
nativeQuery = true)
List<Object[]> findBirthdaysToday();
@Modifying

View File

@@ -57,6 +57,33 @@ public class AuthSessionService {
return Optional.of(state.user());
}
private record SessionState(UserSessionDto user, Instant expiresAt) { }
private record PendingTotpState(UserSessionDto user, Instant expiresAt) { }
public CleanupResult cleanupExpiredSessions() {
Instant now = Instant.now();
int expiredAuthenticated = removeExpiredEntries(authSessions, now);
int expiredPendingTotp = removeExpiredEntries(pendingTotpSessions, now);
return new CleanupResult(expiredAuthenticated, expiredPendingTotp);
}
public record CleanupResult(int expiredAuthenticatedSessions, int expiredPendingTotpSessions) {
public int totalExpiredSessions() {
return expiredAuthenticatedSessions + expiredPendingTotpSessions;
}
}
private <T extends ExpiringState> int removeExpiredEntries(Map<String, T> sessions, Instant now) {
int removed = 0;
for (Map.Entry<String, T> entry : sessions.entrySet()) {
if (entry.getValue().expiresAt().isBefore(now) && sessions.remove(entry.getKey(), entry.getValue())) {
removed++;
}
}
return removed;
}
private sealed interface ExpiringState permits SessionState, PendingTotpState {
Instant expiresAt();
}
private record SessionState(UserSessionDto user, Instant expiresAt) implements ExpiringState { }
private record PendingTotpState(UserSessionDto user, Instant expiresAt) implements ExpiringState { }
}

View File

@@ -0,0 +1,453 @@
package de.votian.services.service;
import de.votian.services.config.LegacySchedulingProperties;
import de.votian.services.config.ParameterKeys;
import de.votian.services.dto.JobAcceptanceProtocolDto;
import de.votian.services.entity.CostCenter;
import de.votian.services.entity.CostCenterAddress;
import de.votian.services.entity.Customer;
import de.votian.services.entity.GenericDataContainer;
import de.votian.services.entity.Job;
import de.votian.services.repository.CostCenterAddressRepository;
import de.votian.services.repository.CostCenterRepository;
import de.votian.services.repository.CustomerRepository;
import de.votian.services.repository.GenericDataContainerRepository;
import de.votian.services.repository.JobRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
@Service
public class AcceptanceProtocolAutomationService {
private static final Logger log = LoggerFactory.getLogger(AcceptanceProtocolAutomationService.class);
private static final String JOB_OBJECT_TYPE = "jb";
private static final String MAIL_FIELD = "mail_srv_acc_prot";
private static final String LETTER_FIELD = "letter_srv_acc_prot";
private static final String IN_PROGRESS = "SEND_IN_PROGRESS";
private static final String MAIL_OK = "MAIL_SENT=OK";
private static final String MAIL_NOT_OK = "MAIL_SENT=NOT_OK";
private static final String LETTER_OK = "LETTER_SENT=OK";
private static final String LETTER_NOT_OK = "LETTER_SENT=NOT_OK";
private static final String PDF_BASE_NAME = "ABNAHMEPROTOKOLL";
private final JdbcTemplate jdbcTemplate;
private final JobRepository jobRepository;
private final CostCenterRepository costCenterRepository;
private final CustomerRepository customerRepository;
private final CostCenterAddressRepository costCenterAddressRepository;
private final GenericDataContainerRepository genericDataContainerRepository;
private final JobAcceptanceProtocolService jobAcceptanceProtocolService;
private final ParameterService parameterService;
private final LegacyFileTransferService legacyFileTransferService;
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final LegacySchedulingProperties schedulingProperties;
private final AtomicBoolean mailRunning = new AtomicBoolean();
private final AtomicBoolean letterRunning = new AtomicBoolean();
public AcceptanceProtocolAutomationService(JdbcTemplate jdbcTemplate,
JobRepository jobRepository,
CostCenterRepository costCenterRepository,
CustomerRepository customerRepository,
CostCenterAddressRepository costCenterAddressRepository,
GenericDataContainerRepository genericDataContainerRepository,
JobAcceptanceProtocolService jobAcceptanceProtocolService,
ParameterService parameterService,
LegacyFileTransferService legacyFileTransferService,
ObjectProvider<JavaMailSender> mailSenderProvider,
LegacySchedulingProperties schedulingProperties) {
this.jdbcTemplate = jdbcTemplate;
this.jobRepository = jobRepository;
this.costCenterRepository = costCenterRepository;
this.customerRepository = customerRepository;
this.costCenterAddressRepository = costCenterAddressRepository;
this.genericDataContainerRepository = genericDataContainerRepository;
this.jobAcceptanceProtocolService = jobAcceptanceProtocolService;
this.parameterService = parameterService;
this.legacyFileTransferService = legacyFileTransferService;
this.mailSenderProvider = mailSenderProvider;
this.schedulingProperties = schedulingProperties;
}
public int dispatchPendingMailProtocols() {
if (!isCronEnabled() || !mailRunning.compareAndSet(false, true)) {
return 0;
}
try {
int processed = 0;
for (Long jobId : loadMailCandidates()) {
if (dispatchMail(jobId)) {
processed++;
}
}
if (processed > 0) {
log.info("Acceptance protocol mail automation processed {} job(s).", processed);
}
return processed;
} finally {
mailRunning.set(false);
}
}
public int dispatchPendingLetterProtocols() {
if (!isCronEnabled() || !letterRunning.compareAndSet(false, true)) {
return 0;
}
try {
int processed = 0;
for (Long jobId : loadLetterCandidates()) {
if (dispatchLetter(jobId)) {
processed++;
}
}
if (processed > 0) {
log.info("Acceptance protocol letter automation processed {} job(s).", processed);
}
return processed;
} finally {
letterRunning.set(false);
}
}
private boolean dispatchMail(Long jobId) {
DispatchContext context = resolveContext(jobId);
if (context == null || !context.protocol().isDataAvailable() || !context.protocol().isSignatureAvailable()) {
return false;
}
MailRecipients recipients = resolveRecipients(context);
if (recipients.to().isEmpty()) {
return false;
}
if (!markInProgress(jobId, MAIL_FIELD)) {
return false;
}
try {
byte[] pdf = jobAcceptanceProtocolService.createPdf(jobId);
sendMail(context, recipients, pdf);
updateDispatchState(jobId, MAIL_FIELD, MAIL_OK);
return true;
} catch (RuntimeException exception) {
log.warn("Acceptance protocol mail dispatch failed for job {}: {}", jobId, exception.getMessage());
updateDispatchState(jobId, MAIL_FIELD, MAIL_NOT_OK);
return false;
}
}
private boolean dispatchLetter(Long jobId) {
DispatchContext context = resolveContext(jobId);
if (context == null || !context.protocol().isDataAvailable() || !context.protocol().isSignatureAvailable()) {
return false;
}
if (!markInProgress(jobId, LETTER_FIELD)) {
return false;
}
try {
byte[] pdf = jobAcceptanceProtocolService.createPdf(jobId);
String fileName = PDF_BASE_NAME + "_" + jobId + ".pdf";
legacyFileTransferService.upload(
schedulingProperties.getAcceptanceProtocolLetterFtpProfile(),
fileName,
pdf
);
updateDispatchState(jobId, LETTER_FIELD, LETTER_OK);
return true;
} catch (RuntimeException exception) {
log.warn("Acceptance protocol letter dispatch failed for job {}: {}", jobId, exception.getMessage());
updateDispatchState(jobId, LETTER_FIELD, LETTER_NOT_OK);
return false;
}
}
private DispatchContext resolveContext(Long jobId) {
Job job = jobRepository.findById(Objects.requireNonNull(jobId)).orElse(null);
if (job == null || job.getHeadquartersId() == null || job.getCostCenterRelatedId() == null) {
return null;
}
CostCenter relatedCostCenter = costCenterRepository.findById(job.getCostCenterRelatedId()).orElse(null);
if (relatedCostCenter == null || relatedCostCenter.getCustomerId() == null) {
return null;
}
Customer customer = customerRepository.findById(relatedCostCenter.getCustomerId()).orElse(null);
if (customer == null) {
return null;
}
JobAcceptanceProtocolDto protocol = jobAcceptanceProtocolService.getProtocol(jobId);
Long groupId = extractFirstNumericGroup(customer.getGroup());
return new DispatchContext(job, customer, protocol, groupId);
}
private MailRecipients resolveRecipients(DispatchContext context) {
long headquartersId = context.job().getHeadquartersId();
Long customerId = context.customer().getId();
Long groupId = context.groupId();
String mailTo = firstNonBlank(
parameterService.getObjectBasedParameterValue(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_TO + "_CS", customerId, headquartersId, 0L, true),
groupId != null
? parameterService.getObjectBasedParameterValue(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_TO + "_GRP", groupId, headquartersId, 0L, true)
: "",
parameterService.getParameterValueWithFallback(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_TO, headquartersId)
);
String mailCc = firstNonBlank(
parameterService.getObjectBasedParameterValue(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_CC + "_CS", customerId, headquartersId, 0L, true),
groupId != null
? parameterService.getObjectBasedParameterValue(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_CC + "_GRP", groupId, headquartersId, 0L, true)
: "",
parameterService.getParameterValueWithFallback(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_CC, headquartersId)
);
String mailBcc = firstNonBlank(
parameterService.getObjectBasedParameterValue(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_BCC + "_CS", customerId, headquartersId, 0L, true),
groupId != null
? parameterService.getObjectBasedParameterValue(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_BCC + "_GRP", groupId, headquartersId, 0L, true)
: "",
parameterService.getParameterValueWithFallback(ParameterKeys.AUTOMAILER_ACCEPTANCE_PROTOCOL_MAIL_BCC, headquartersId)
);
LinkedHashSet<String> toRecipients = splitRecipients(mailTo);
resolvePayerMailAddress(context.job().getCostCenterPayerId()).ifPresent(toRecipients::add);
return new MailRecipients(
List.copyOf(toRecipients),
List.copyOf(splitRecipients(mailCc)),
List.copyOf(splitRecipients(mailBcc))
);
}
private Optional<String> resolvePayerMailAddress(Long payerCostCenterId) {
if (payerCostCenterId == null) {
return Optional.empty();
}
for (int addressTypeId : List.of(4, 2, 3, 1)) {
Optional<String> address = costCenterAddressRepository.findByCostCenterIdAndAddressTypeId(payerCostCenterId, addressTypeId)
.map(CostCenterAddress::getEmail)
.map(this::normalizeRecipient)
.filter(value -> !value.isBlank());
if (address.isPresent()) {
return address;
}
}
return Optional.empty();
}
private void sendMail(DispatchContext context, MailRecipients recipients, byte[] pdf) {
JavaMailSender mailSender = mailSenderProvider.getIfAvailable();
if (mailSender == null) {
throw new IllegalStateException("Kein JavaMailSender konfiguriert.");
}
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setTo(recipients.to().toArray(String[]::new));
if (!recipients.cc().isEmpty()) {
helper.setCc(recipients.cc().toArray(String[]::new));
}
if (!recipients.bcc().isEmpty()) {
helper.setBcc(recipients.bcc().toArray(String[]::new));
}
helper.setSubject(PDF_BASE_NAME + " (" + context.job().getId() + ")");
helper.setText(buildMailBody(context), false);
String senderAddress = parameterService.getParameterValueWithFallback(ParameterKeys.MAIL_SENDER_ADDRESS, context.job().getHeadquartersId());
if (!senderAddress.isBlank()) {
helper.setFrom(senderAddress);
helper.setReplyTo(senderAddress);
}
helper.addAttachment(PDF_BASE_NAME + "_" + context.job().getId() + ".pdf", new ByteArrayResource(pdf));
} catch (MessagingException exception) {
throw new IllegalStateException("Mail konnte nicht vorbereitet werden.", exception);
}
try {
mailSender.send(message);
} catch (MailException exception) {
throw new IllegalStateException("Mailversand fehlgeschlagen.", exception);
}
}
private String buildMailBody(DispatchContext context) {
StringBuilder body = new StringBuilder();
body.append("Sehr geehrte Damen und Herren,").append("\n\n");
body.append("im Anhang erhalten Sie das Abnahmeprotokoll zum Auftrag ")
.append(context.job().getId())
.append('.')
.append("\n\n");
if (context.protocol().getProtocolServiceLabel() != null && !context.protocol().getProtocolServiceLabel().isBlank()) {
body.append("Protokoll-Service: ")
.append(context.protocol().getProtocolServiceLabel())
.append("\n");
}
body.append("\nMit freundlichen Gruessen");
return body.toString();
}
private boolean markInProgress(Long jobId, String fieldName) {
Optional<GenericDataContainer> existing = genericDataContainerRepository.findByTypeAndIdAndField(JOB_OBJECT_TYPE, jobId, fieldName);
if (existing.isPresent() && contains(existing.get().getContext(), IN_PROGRESS)) {
return false;
}
GenericDataContainer container = existing.orElseGet(GenericDataContainer::new);
container.setObjectType(JOB_OBJECT_TYPE);
container.setObjectId(jobId);
container.setFieldName(fieldName);
container.setContent(existing.map(GenericDataContainer::getContent).orElse(""));
container.setContext(IN_PROGRESS);
genericDataContainerRepository.save(container);
return true;
}
private void updateDispatchState(Long jobId, String fieldName, String context) {
GenericDataContainer container = genericDataContainerRepository.findByTypeAndIdAndField(JOB_OBJECT_TYPE, jobId, fieldName)
.orElseGet(GenericDataContainer::new);
container.setObjectType(JOB_OBJECT_TYPE);
container.setObjectId(jobId);
container.setFieldName(fieldName);
container.setContent(container.getContent() != null ? container.getContent() : "");
container.setContext(context);
genericDataContainerRepository.save(container);
}
private List<Long> loadMailCandidates() {
return jdbcTemplate.queryForList(
"""
SELECT DISTINCT jb.jb_id
FROM job AS jb
JOIN costcenteraddress AS cscad ON cscad.csc_id = jb.csc_id_payer AND cscad.adt_id = 2
LEFT JOIN genericdatacontainer AS gdc
ON gdc.gdc_obj_type = 'jb'
AND gdc.gdc_obj_id = jb.jb_id
AND gdc.gdc_gen_fieldname = ?
WHERE jb.jb_finishtime > ?
AND (jb.jb_storno IS NULL OR jb.jb_storno = '0')
AND jb.jb_status = 2
AND COALESCE(jb.jb_offer, 0) = 0
AND (jb.jb_service & 2) = 2
AND COALESCE(cscad.cscad_email, '') <> ''
AND (gdc.gdc_id IS NULL
OR (COALESCE(gdc.gdc_context, '') NOT LIKE '%MAIL_SENT=OK%'
AND COALESCE(gdc.gdc_context, '') NOT LIKE '%SEND_IN_PROGRESS%'))
ORDER BY jb.jb_id
""",
Long.class,
MAIL_FIELD,
Timestamp.valueOf(LocalDateTime.now().minusDays(Math.max(1, schedulingProperties.getAcceptanceProtocolMailLookbackDays())))
);
}
private List<Long> loadLetterCandidates() {
return jdbcTemplate.queryForList(
"""
SELECT DISTINCT jb.jb_id
FROM job AS jb
JOIN costcenteraddress AS cscad ON cscad.csc_id = jb.csc_id_payer AND cscad.adt_id = 2
LEFT JOIN genericdatacontainer AS gdc
ON gdc.gdc_obj_type = 'jb'
AND gdc.gdc_obj_id = jb.jb_id
AND gdc.gdc_gen_fieldname = ?
WHERE jb.jb_finishtime > ?
AND (jb.jb_storno IS NULL OR jb.jb_storno = '0')
AND jb.jb_status = 2
AND COALESCE(jb.jb_offer, 0) = 0
AND (jb.jb_service & 2) = 2
AND COALESCE(cscad.cscad_email, '') = ''
AND (gdc.gdc_id IS NULL
OR (COALESCE(gdc.gdc_context, '') NOT LIKE '%LETTER_SENT=OK%'
AND COALESCE(gdc.gdc_context, '') NOT LIKE '%SEND_IN_PROGRESS%'))
ORDER BY jb.jb_id
""",
Long.class,
LETTER_FIELD,
Timestamp.valueOf(LocalDateTime.now().minusDays(Math.max(1, schedulingProperties.getAcceptanceProtocolLetterLookbackDays())))
);
}
private boolean isCronEnabled() {
return "1".equals(parameterService.getParameterValueWithFallback(ParameterKeys.GLOBAL_CRON_ENABLED, 0L))
&& "1".equals(parameterService.getParameterValueWithFallback(
ParameterKeys.CRON_SERVICE_ACCEPTANCE_PROTOCOL_ENABLED,
0L
));
}
private Long extractFirstNumericGroup(String rawValue) {
if (rawValue == null || rawValue.isBlank()) {
return null;
}
for (String token : rawValue.split(",")) {
String normalized = token != null ? token.trim() : "";
if (normalized.matches("\\d+")) {
return Long.valueOf(normalized);
}
}
return null;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
private LinkedHashSet<String> splitRecipients(String rawRecipients) {
LinkedHashSet<String> recipients = new LinkedHashSet<>();
if (rawRecipients == null || rawRecipients.isBlank()) {
return recipients;
}
for (String token : rawRecipients.split("[,;]")) {
String normalized = normalizeRecipient(token);
if (!normalized.isBlank()) {
recipients.add(normalized);
}
}
return recipients;
}
private String normalizeRecipient(String value) {
String normalized = value != null ? value.trim() : "";
if (normalized.isBlank()) {
return "";
}
return normalized.toLowerCase(Locale.ROOT);
}
private boolean contains(String value, String token) {
return value != null && value.contains(token);
}
private record DispatchContext(Job job, Customer customer, JobAcceptanceProtocolDto protocol, Long groupId) {
}
private record MailRecipients(List<String> to, List<String> cc, List<String> bcc) {
}
}

View File

@@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
@@ -90,6 +91,15 @@ public class JobService {
return jobRepository.findTodaysJobs();
}
public long countTodaysJobs(Long headquartersId) {
if (headquartersId == null) {
return 0L;
}
LocalDateTime from = LocalDate.now().atStartOfDay();
LocalDateTime to = from.plusDays(1);
return jobRepository.countByOrderTimeBetweenAndHq(headquartersId, from, to);
}
@Transactional
public Job createJob(Job job, List<Tour> tours, List<TourArticle> articles) {
return createJob(job, tours, articles, List.of());

View File

@@ -0,0 +1,171 @@
package de.votian.services.service;
import de.votian.services.config.LegacySchedulingProperties;
import de.votian.services.config.ParameterKeys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@Service
public class LegacyCourierAutoLogoutService {
private static final Logger log = LoggerFactory.getLogger(LegacyCourierAutoLogoutService.class);
private final JdbcTemplate jdbcTemplate;
private final ParameterService parameterService;
private final LegacySchedulingProperties schedulingProperties;
private final AtomicBoolean running = new AtomicBoolean();
public LegacyCourierAutoLogoutService(JdbcTemplate jdbcTemplate,
ParameterService parameterService,
LegacySchedulingProperties schedulingProperties) {
this.jdbcTemplate = jdbcTemplate;
this.parameterService = parameterService;
this.schedulingProperties = schedulingProperties;
}
@Transactional
public int logoutCouriersByAutorevokes() {
if (!running.compareAndSet(false, true)) {
log.info("Legacy courier auto logout is still running, skipping overlapping execution.");
return 0;
}
try {
List<CourierLogState> candidates = loadCandidates(schedulingProperties.getCourierAutoLogoutHeadquartersId());
int loggedOut = 0;
for (CourierLogState candidate : candidates) {
if (candidate.acceptedCount() > 0 || candidate.cancelledCount() > 0 || candidate.revokedCount() < 2) {
continue;
}
if (isOccupied(candidate.courierId())) {
continue;
}
logoutCourier(candidate);
loggedOut++;
}
if (loggedOut > 0) {
log.info("Legacy courier auto logout logged out {} courier(s).", loggedOut);
}
return loggedOut;
} finally {
running.set(false);
}
}
private List<CourierLogState> loadCandidates(long headquartersId) {
return jdbcTemplate.query(
"""
SELECT cr.cr_id,
cr.cr_eid,
cr.hq_id,
SUM(CASE WHEN log.logo_id = 3 THEN 1 ELSE 0 END) AS accepted_count,
SUM(CASE WHEN log.logo_id = 8 THEN 1 ELSE 0 END) AS revoked_count,
SUM(CASE WHEN log.logo_id = 11 THEN 1 ELSE 0 END) AS cancelled_count
FROM courier AS cr
JOIN phoenix_log.log AS log ON log.cr_id = cr.cr_id
WHERE cr.cr_available = '1'
AND cr.hq_id = ?
AND cr.cr_locationzipcode REGEXP '^[0-9]+$'
AND log.log_createtime >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
AND log.logo_id IN (3, 8, 11)
GROUP BY cr.cr_id, cr.cr_eid, cr.hq_id
ORDER BY cr.cr_id
""",
(rs, rowNum) -> new CourierLogState(
rs.getLong("cr_id"),
rs.getString("cr_eid"),
rs.getLong("hq_id"),
rs.getInt("accepted_count"),
rs.getInt("revoked_count"),
rs.getInt("cancelled_count")
),
headquartersId
);
}
private boolean isOccupied(long courierId) {
Integer matches = jdbcTemplate.queryForObject(
"""
SELECT COUNT(*)
FROM courier AS cr
JOIN job AS jb ON jb.cr_id_order = cr.cr_id
JOIN tour AS tr ON tr.jb_id = jb.jb_id AND tr.tr_sort = 1
JOIN address AS ad ON tr.ad_id = ad.ad_id
LEFT JOIN serviceplz AS srvp ON ad.ad_zipcode = srvp.srvp_plz
LEFT JOIN serviceplztraveltime AS srvpt ON srvp.srvp_id = srvpt.srvp_id
WHERE cr.cr_id = ?
AND jb.jb_status IN (0, 1)
AND DATE_SUB(jb.jb_ordertime, INTERVAL GREATEST(COALESCE(srvpt.srvpt_traveltime, 0), 30) MINUTE) <= NOW()
AND (srvpt.hq_id = jb.hq_id OR srvpt.hq_id IS NULL)
""",
Integer.class,
courierId
);
return matches != null && matches > 0;
}
private void logoutCourier(CourierLogState candidate) {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime executeUntil = now.plusMinutes(16);
jdbcTemplate.update(
"UPDATE courier SET cr_availabletime = ?, cr_locationzipcode = ? WHERE cr_id = ?",
Timestamp.valueOf(now),
"LOGOUT",
candidate.courierId()
);
jdbcTemplate.update(
"""
INSERT INTO phoenix_pda.commandexec
(cr_id, hq_id, cmd_command, cmde_exec_time, cmde_exec_timelimit, cmde_mode, cmde_param)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
candidate.courierId(),
candidate.headquartersId(),
"2",
Timestamp.valueOf(now),
Timestamp.valueOf(executeUntil),
"1",
""
);
if ("1".equals(parameterService.getParameterValueWithFallback(ParameterKeys.LOG_DB, candidate.headquartersId()))) {
jdbcTemplate.update(
"""
INSERT INTO phoenix_log.log
(logo_id, hq_id, jb_id, usr_id, cr_id, cr_sid, cs_id, at_id, pt_id, emp_id, logo_description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
143,
candidate.headquartersId(),
0,
0,
candidate.courierId(),
"",
0,
0,
0,
0,
"STATUS_LOGOUT=PDA-LOGOUT DURCH AUTOREVOKES"
);
}
log.info("Legacy courier auto logout logged out courier {} ({})", candidate.courierId(), candidate.courierEid());
}
private record CourierLogState(long courierId,
String courierEid,
long headquartersId,
int acceptedCount,
int revokedCount,
int cancelledCount) {
}
}

View File

@@ -0,0 +1,231 @@
package de.votian.services.service;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpException;
import de.votian.services.config.ParameterKeys;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPSClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
@Service
public class LegacyFileTransferService {
private static final Logger log = LoggerFactory.getLogger(LegacyFileTransferService.class);
private final ParameterService parameterService;
public LegacyFileTransferService(ParameterService parameterService) {
this.parameterService = parameterService;
}
public void upload(String profile, String fileName, byte[] content) {
TransferProfile transferProfile = loadProfile(profile);
switch (transferProfile.sslMode()) {
case "2" -> uploadViaSftp(transferProfile, fileName, content);
case "1" -> uploadViaFtps(transferProfile, fileName, content);
default -> uploadViaFtp(transferProfile, fileName, content);
}
}
private TransferProfile loadProfile(String profile) {
String normalizedProfile = profile != null ? profile.trim().toUpperCase() : "";
if (normalizedProfile.isBlank()) {
throw new IllegalArgumentException("FTP-Profil fehlt.");
}
String server = loadProfileValue(ParameterKeys.FTP_SERVER_PREFIX, normalizedProfile);
String user = loadProfileValue(ParameterKeys.FTP_USER_PREFIX, normalizedProfile);
String password = loadProfileValue(ParameterKeys.FTP_PASSWORD_PREFIX, normalizedProfile);
String sslMode = blankToDefault(loadProfileValue(ParameterKeys.FTP_SSL, normalizedProfile), "0");
String remotePath = blankToDefault(loadProfileValue(ParameterKeys.FTP_REMOTEPATH, normalizedProfile), "/");
if (server.isBlank() || user.isBlank() || password.isBlank()) {
throw new IllegalStateException("FTP-Profil " + normalizedProfile + " ist unvollstaendig konfiguriert.");
}
Endpoint endpoint = parseEndpoint(server, "2".equals(sslMode) ? 22 : 21);
return new TransferProfile(normalizedProfile, endpoint.host(), endpoint.port(), user, password, sslMode, normalizeDirectory(remotePath));
}
private String loadProfileValue(String keyPrefix, String profile) {
return blankToEmpty(parameterService.getParameterValueWithFallback(keyPrefix + profile, 0L));
}
private void uploadViaFtp(TransferProfile profile, String fileName, byte[] content) {
FTPClient client = new FTPClient();
try {
connectAndLogin(client, profile);
storeFile(client, profile.remotePath(), fileName, content);
} catch (IOException exception) {
throw new IllegalStateException("FTP-Upload fuer Profil " + profile.profileName() + " fehlgeschlagen.", exception);
} finally {
disconnectQuietly(client);
}
}
private void uploadViaFtps(TransferProfile profile, String fileName, byte[] content) {
FTPSClient client = new FTPSClient(false);
try {
connectAndLogin(client, profile);
client.execPBSZ(0);
client.execPROT("P");
storeFile(client, profile.remotePath(), fileName, content);
} catch (IOException exception) {
throw new IllegalStateException("FTPS-Upload fuer Profil " + profile.profileName() + " fehlgeschlagen.", exception);
} finally {
disconnectQuietly(client);
}
}
private void connectAndLogin(FTPClient client, TransferProfile profile) throws IOException {
client.connect(profile.host(), profile.port());
if (!client.login(profile.user(), profile.password())) {
throw new IOException("FTP-Login fehlgeschlagen.");
}
client.enterLocalPassiveMode();
client.setFileType(FTP.BINARY_FILE_TYPE);
}
private void storeFile(FTPClient client, String remoteDirectory, String fileName, byte[] content) throws IOException {
ensureFtpDirectory(client, remoteDirectory);
try (InputStream inputStream = new ByteArrayInputStream(content)) {
if (!client.storeFile(fileName, inputStream)) {
throw new IOException("Datei konnte nicht gespeichert werden. Reply-Code: " + client.getReplyCode());
}
}
}
private void ensureFtpDirectory(FTPClient client, String remoteDirectory) throws IOException {
String[] segments = remoteDirectory.split("/");
if (remoteDirectory.startsWith("/")) {
client.changeWorkingDirectory("/");
}
for (String segment : segments) {
if (segment == null || segment.isBlank()) {
continue;
}
if (!client.changeWorkingDirectory(segment)) {
if (!client.makeDirectory(segment) || !client.changeWorkingDirectory(segment)) {
throw new IOException("FTP-Verzeichnis konnte nicht angelegt werden: " + remoteDirectory);
}
}
}
}
private void disconnectQuietly(FTPClient client) {
if (client == null) {
return;
}
try {
if (client.isConnected()) {
client.logout();
client.disconnect();
}
} catch (IOException exception) {
log.debug("FTP disconnect failed: {}", exception.getMessage());
}
}
private void uploadViaSftp(TransferProfile profile, String fileName, byte[] content) {
Session session = null;
ChannelSftp channel = null;
try {
JSch jsch = new JSch();
session = jsch.getSession(profile.user(), profile.host(), profile.port());
session.setPassword(profile.password());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect();
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect();
ensureSftpDirectory(channel, profile.remotePath());
channel.put(new ByteArrayInputStream(content), fileName);
} catch (JSchException | SftpException exception) {
throw new IllegalStateException("SFTP-Upload fuer Profil " + profile.profileName() + " fehlgeschlagen.", exception);
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
}
private void ensureSftpDirectory(ChannelSftp channel, String remoteDirectory) throws SftpException {
channel.cd("/");
for (String segment : remoteDirectory.split("/")) {
if (segment == null || segment.isBlank()) {
continue;
}
try {
channel.cd(segment);
} catch (SftpException exception) {
channel.mkdir(segment);
channel.cd(segment);
}
}
}
private String normalizeDirectory(String value) {
String normalized = value != null ? value.trim() : "";
if (normalized.isBlank()) {
return "/";
}
normalized = normalized.replace('\\', '/');
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private Endpoint parseEndpoint(String value, int defaultPort) {
String normalized = blankToEmpty(value);
int separator = normalized.lastIndexOf(':');
if (separator > 0 && separator < normalized.length() - 1 && normalized.indexOf(':') == separator) {
String host = normalized.substring(0, separator).trim();
String portValue = normalized.substring(separator + 1).trim();
if (portValue.matches("\\d+")) {
return new Endpoint(host, Integer.parseInt(portValue));
}
}
return new Endpoint(normalized, defaultPort);
}
private String blankToEmpty(String value) {
return value != null ? value.trim() : "";
}
private String blankToDefault(String value, String fallback) {
String normalized = blankToEmpty(value);
return normalized.isBlank() ? fallback : normalized;
}
private record TransferProfile(String profileName,
String host,
int port,
String user,
String password,
String sslMode,
String remotePath) {
}
private record Endpoint(String host, int port) {
}
}

View File

@@ -0,0 +1,70 @@
package de.votian.services.service;
import de.votian.services.config.LegacySchedulingProperties;
import de.votian.services.security.AuthSessionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class LegacyOperationsScheduler {
private static final Logger log = LoggerFactory.getLogger(LegacyOperationsScheduler.class);
private final LegacySchedulingProperties schedulingProperties;
private final AuthSessionService authSessionService;
private final LegacyCourierAutoLogoutService legacyCourierAutoLogoutService;
private final AcceptanceProtocolAutomationService acceptanceProtocolAutomationService;
public LegacyOperationsScheduler(LegacySchedulingProperties schedulingProperties,
AuthSessionService authSessionService,
LegacyCourierAutoLogoutService legacyCourierAutoLogoutService,
AcceptanceProtocolAutomationService acceptanceProtocolAutomationService) {
this.schedulingProperties = schedulingProperties;
this.authSessionService = authSessionService;
this.legacyCourierAutoLogoutService = legacyCourierAutoLogoutService;
this.acceptanceProtocolAutomationService = acceptanceProtocolAutomationService;
}
@Scheduled(cron = "${votian.scheduling.auth-session-cleanup-cron:0 */5 * * * *}",
zone = "${votian.scheduling.zone:Europe/Berlin}")
public void cleanupExpiredAuthSessions() {
if (!schedulingProperties.isEnabled()) {
return;
}
AuthSessionService.CleanupResult result = authSessionService.cleanupExpiredSessions();
if (result.totalExpiredSessions() > 0) {
log.info("Expired {} auth session(s) and {} pending TOTP session(s).",
result.expiredAuthenticatedSessions(),
result.expiredPendingTotpSessions());
}
}
@Scheduled(cron = "${votian.scheduling.courier-auto-logout-cron:0 */30 * * * *}",
zone = "${votian.scheduling.zone:Europe/Berlin}")
public void runCourierAutoLogout() {
if (!schedulingProperties.isEnabled()) {
return;
}
legacyCourierAutoLogoutService.logoutCouriersByAutorevokes();
}
@Scheduled(cron = "${votian.scheduling.acceptance-protocol-mail-cron:0 */5 * * * *}",
zone = "${votian.scheduling.zone:Europe/Berlin}")
public void runAcceptanceProtocolMailDispatch() {
if (!schedulingProperties.isEnabled()) {
return;
}
acceptanceProtocolAutomationService.dispatchPendingMailProtocols();
}
@Scheduled(cron = "${votian.scheduling.acceptance-protocol-letter-cron:30 */5 * * * *}",
zone = "${votian.scheduling.zone:Europe/Berlin}")
public void runAcceptanceProtocolLetterDispatch() {
if (!schedulingProperties.isEnabled()) {
return;
}
acceptanceProtocolAutomationService.dispatchPendingLetterProtocols();
}
}

View File

@@ -10,6 +10,7 @@ import de.votian.services.entity.Job;
import de.votian.services.entity.ServiceZipCode;
import de.votian.services.entity.Tour;
import de.votian.services.entity.VehicleDisposition;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -39,6 +40,7 @@ public class LonghaulRemoteDbService {
private final ParameterService parameterService;
private final ConnectionFactory connectionFactory;
@Autowired
public LonghaulRemoteDbService(ParameterService parameterService) {
this(parameterService, new DriverManagerConnectionFactory());
}

View File

@@ -113,15 +113,11 @@ public class ViewDataService {
}
public List<CustomerListItemDto> findCustomersByHeadquarters(Long hqId) {
return customerRepository.findByHeadquartersId(hqId).stream()
.map(this::toCustomerListItem)
.toList();
return toCustomerListItems(customerRepository.findByHeadquartersId(hqId));
}
public List<CustomerListItemDto> searchCustomers(Long hqId, String term) {
return customerRepository.searchCustomers(hqId, term).stream()
.map(this::toCustomerListItem)
.toList();
return toCustomerListItems(customerRepository.searchCustomers(hqId, term));
}
public Optional<CustomerDetailDto> findCustomerDetail(Long id) {
@@ -129,15 +125,11 @@ public class ViewDataService {
}
public List<CourierListItemDto> findCouriersByHeadquarters(Long hqId) {
return courierRepository.findByHeadquartersId(hqId).stream()
.map(this::toCourierListItem)
.toList();
return toCourierListItems(courierRepository.findByHeadquartersId(hqId));
}
public List<CourierListItemDto> searchCouriers(Long hqId, String term) {
return courierRepository.searchCouriers(hqId, term).stream()
.map(this::toCourierListItem)
.toList();
return toCourierListItems(courierRepository.searchCouriers(hqId, term));
}
public Optional<CourierDetailDto> findCourierDetail(Long id) {
@@ -475,10 +467,7 @@ public class ViewDataService {
}
public List<CostCenter> findJobFilterCostCenters(Long hqId, List<Long> accessibleCostCenterIds) {
List<CostCenter> costCenters = new ArrayList<>();
for (Customer customer : customerRepository.findByHeadquartersId(hqId)) {
costCenters.addAll(findVisibleCostCentersSorted(customer.getId()));
}
List<CostCenter> costCenters = new ArrayList<>(costCenterRepository.findVisibleNonExternByHeadquartersId(hqId));
if (accessibleCostCenterIds != null) {
costCenters = filterCostCentersByAccess(costCenters, accessibleCostCenterIds);
}
@@ -525,6 +514,51 @@ public class ViewDataService {
return dto;
}
private List<CustomerListItemDto> toCustomerListItems(List<Customer> customers) {
Map<Long, Company> companiesById = new LinkedHashMap<>();
for (Company company : companyRepository.findAllById(customers.stream()
.map(Customer::getCompanyId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)))) {
companiesById.put(company.getId(), company);
}
Map<Long, Employee> employeesById = new LinkedHashMap<>();
for (Employee employee : employeeRepository.findAllById(customers.stream()
.map(Customer::getAdminEmployeeId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)))) {
employeesById.put(employee.getId(), employee);
}
Map<Long, User> usersById = new LinkedHashMap<>();
for (User user : userRepository.findAllById(employeesById.values().stream()
.map(Employee::getUserId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)))) {
usersById.put(user.getId(), user);
}
return customers.stream()
.map(customer -> {
Company company = companiesById.get(customer.getCompanyId());
Employee employee = employeesById.get(customer.getAdminEmployeeId());
User user = employee != null ? usersById.get(employee.getUserId()) : null;
CustomerListItemDto dto = new CustomerListItemDto();
dto.setId(customer.getId());
dto.setEid(customer.getEid());
dto.setCompanyName(company != null && company.getComp() != null && !company.getComp().isBlank()
? company.getComp()
: customer.getEid());
dto.setContact(user != null ? joinName(user.getFirstname(), user.getName()) : "");
dto.setEmail(user != null && user.getEmail() != null ? user.getEmail() : "");
dto.setPhone(user != null && user.getPhone() != null ? user.getPhone() : "");
return dto;
})
.toList();
}
private CustomerDetailDto toCustomerDetail(Customer customer) {
CustomerDetailDto dto = new CustomerDetailDto();
dto.setId(customer.getId());
@@ -568,6 +602,42 @@ public class ViewDataService {
return dto;
}
private List<CourierListItemDto> toCourierListItems(List<Courier> couriers) {
Map<Long, Company> companiesById = new LinkedHashMap<>();
for (Company company : companyRepository.findAllById(couriers.stream()
.map(Courier::getCompanyId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)))) {
companiesById.put(company.getId(), company);
}
Map<Long, User> usersById = new LinkedHashMap<>();
for (User user : userRepository.findAllById(couriers.stream()
.map(Courier::getUserId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)))) {
usersById.put(user.getId(), user);
}
return couriers.stream()
.map(courier -> {
Company company = companiesById.get(courier.getCompanyId());
User user = usersById.get(courier.getUserId());
CourierListItemDto dto = new CourierListItemDto();
dto.setId(courier.getId());
dto.setEid(courier.getEid());
dto.setSid(courier.getSid());
dto.setCompanyName(company != null && company.getComp() != null && !company.getComp().isBlank()
? company.getComp()
: courier.getSid());
dto.setContact(user != null ? joinName(user.getFirstname(), user.getName()) : "");
dto.setAvailable("1".equals(courier.getAvailable()) ? "Ja" : "Nein");
return dto;
})
.toList();
}
private CourierDetailDto toCourierDetail(Courier courier) {
CourierDetailDto dto = new CourierDetailDto();
dto.setId(courier.getId());

View File

@@ -28,3 +28,15 @@ votian.storage.job-station-photos-dir=../html/temp/photos
# Logging
logging.level.de.votian=INFO
logging.level.org.hibernate.SQL=WARN
# Legacy scheduler bridge
votian.scheduling.enabled=true
votian.scheduling.zone=Europe/Berlin
votian.scheduling.auth-session-cleanup-cron=0 */5 * * * *
votian.scheduling.courier-auto-logout-cron=0 */30 * * * *
votian.scheduling.courier-auto-logout-headquarters-id=3
votian.scheduling.acceptance-protocol-mail-cron=0 */5 * * * *
votian.scheduling.acceptance-protocol-letter-cron=30 */5 * * * *
votian.scheduling.acceptance-protocol-mail-lookback-days=1
votian.scheduling.acceptance-protocol-letter-lookback-days=3
votian.scheduling.acceptance-protocol-letter-ftp-profile=MPS1

View File

@@ -63,8 +63,14 @@ public class DashboardView extends VerticalLayout {
// Today's jobs count
try {
Object[] todayJobs = restClient.get("/jobs/today", Object[].class);
cards.add(createInfoCard("Heutige Auftraege", String.valueOf(todayJobs != null ? todayJobs.length : 0)));
@SuppressWarnings("unchecked")
Map<String, Object> result = restClient.get(
"/jobs/today/count",
LinkedHashMap.class
);
Object rawCount = result != null ? result.get("count") : null;
long count = rawCount instanceof Number ? ((Number) rawCount).longValue() : 0L;
cards.add(createInfoCard("Heutige Auftraege", String.valueOf(count)));
} catch (Exception e) {
cards.add(createInfoCard("Heutige Auftraege", "N/A"));
}