feat(ki): Einstellungen -> KI mit OpenAI-Schluessel und Modellwahl

Neuer Admin-Reiter "KI": zeigt die letzten vier Zeichen des hinterlegten
OpenAI-Schluessels samt Quelle, nimmt einen neuen an (vorher gegen
/v1/models geprueft) und laesst je Einsatzzweck das Modell per Dropdown
waehlen - Scanner-Bewertung, Abgleich "bereits beworben", erweiterte Suche.
Die Auswahl kommt live von OpenAI, Vorgaben sind die bisher fest
verdrahteten Modelle. Reasoning-Modelle bekommen reasoning_effort statt
temperature, damit ein Wechsel keinen 400er ausloest.

Schluesselfeld aus dem Scanner-Reiter entfernt; Doku in docs/14-ki.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-23 10:31:55 +02:00
co-authored by Claude Opus 5.5
parent 0d41d46a3c
commit e35c7518db
26 changed files with 930 additions and 144 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ Einzelheiten: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
R -->|admin| A["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Scanner, Whisper"]
R -->|admin| A["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Anmeldung, Scanner, KI, Whisper"]
R -->|user| U["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Akquise, Whisper"]
```
+3
View File
@@ -382,6 +382,9 @@ erDiagram
- **`settings` ist ein Schlüssel-Wert-Speicher.** Geheimnisse (`openai_api_key`,
`smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder
maskiertes Feld beim Speichern lässt den gespeicherten Wert unberührt.
`openai_api_key` wird seit dem 23.09.2026 unter *Einstellungen → KI* gepflegt
(siehe [14 KI](14-ki.md)); die Modellwahl dazu steht in `crm_settings` als
`ai_model_matching`, `ai_model_applied` und `ai_model_search`.
## Statuswege
+4 -3
View File
@@ -90,8 +90,9 @@ flowchart TD
Entscheidungen dahinter:
- **Eigenes Modell.** Die Bewertung CV gegen Projekt bleibt bei `gpt-4o-mini`;
der Abgleich nutzt `gpt-5.6-luna` (Konstante `APPLIED_CHECK_MODEL`). Er muss
- **Eigenes Modell.** Die Bewertung CV gegen Projekt nutzt als Vorgabe
`gpt-4o-mini`, der Abgleich `gpt-5.6-luna` - beide umstellbar unter
*Einstellungen → KI* (siehe [14 KI](14-ki.md)). Der Abgleich muss
umformulierte Ausschreibungen als dieselbe erkennen und ähnliche Rollen bei
anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich.
Der Schlüssel ist derselbe `openai_api_key`.
@@ -284,7 +285,7 @@ Zertifikatswarnung. Der DNS-Eintrag muss beim Anbieter gelöscht werden.
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
| Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, OpenAI, Ton, E-Mail |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, Ton, E-Mail (Schlüssel und Modelle: `settings/AiSettings.tsx`) |
**Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
+2 -1
View File
@@ -129,7 +129,8 @@ entfallen.
| --- | --- |
| Anmeldung schlägt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? |
| Scanner-Reiter leer, 500er | `[Scanner] Datenbank nicht erreichbar` im Journal |
| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt |
| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt (*Einstellungen → KI*) |
| KI-Fehler „model not found“ | Modell unter *Einstellungen → KI* auf eines aus der Liste stellen |
| Portal `expired` | Cookies neu importieren (Cookie-Editor) |
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
+14 -1
View File
@@ -949,7 +949,8 @@ Sitzung kommt derzeit nur über den Cookie-Import.
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Kanal*, *Berater* |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, Ton, E-Mail |
| **KI** | Admin | OpenAI-Schlüssel, Modell je Einsatzzweck |
| **Whisper** | alle | Spracherkennung für das Diktat einrichten |
Im Reiter **Akquise** stehen die drei Auswahllisten des Akquise-Formulars, jede
@@ -976,6 +977,18 @@ Unter **Abgleich mit Akquise und Opportunities** lässt sich die Markierung
ein Akquise-Eintrag oder eine Opportunity höchstens sein darf, um verglichen zu
werden - in Tagen oder Wochen, Vorgabe zwei Wochen.
Im Reiter **KI** steht links der **OpenAI-Schlüssel**. Zu sehen ist nur sein
Ende (etwa `sk-…x7Qa`) und woher er stammt. Einen neuen trägst du unter *Neuer
Schlüssel* ein und klickst **Speichern** - das CRM fragt damit kurz bei OpenAI an
und übernimmt ihn nur, wenn OpenAI ihn annimmt. Bis dahin bleibt der alte gültig.
Rechts wählst du je Einsatzzweck das **Modell**: für die Bewertung im Scanner,
für den Abgleich „bereits beworben!“ und für die erweiterte Suche. Die Liste
kommt direkt von OpenAI; *(Vorgabe)* markiert das ursprüngliche Modell. Nach
**Modellwahl speichern** gilt die Wahl ab dem nächsten Aufruf.
![Einstellungen, Reiter KI](bilder/einstellungen-ki.png)
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
+24
View File
@@ -231,6 +231,30 @@ Eigenheiten, die man kennen muss:
Funktionen `mailConfigured()` und `ssoConfigured()` - die Prüfung muss in die
Datenbank sehen.
### `server/src/ai.js`
OpenAI-Schlüssel, Modellwahl je Einsatzzweck und der Aufruf der erweiterten
Suche. Gepflegt unter *Einstellungen → KI*, Details in [14 KI](14-ki.md).
| Export | Zweck |
| --- | --- |
| `aiKey()` / `aiConfigured()` | gültiger Schlüssel, Datenbank vor `.env` |
| `keyStatus()` | Quelle und letzte vier Zeichen - nie der ganze Schlüssel |
| `verifyKey()` / `saveKey()` / `clearKey()` | Probe bei OpenAI, schreiben, zurücknehmen |
| `AI_PURPOSES`, `modelFor(zweck)` | Einsatzzwecke mit Vorgabe; eingestelltes Modell oder Vorgabe |
| `modelSettings()` / `saveModels()` / `availableModels()` | Modellwahl lesen, schreiben, Auswahlliste von OpenAI |
| `isReasoningModel()` | `reasoning_effort` statt `temperature`, in `test/ai.test.mjs` geprüft |
| `askModelJson()` | ein Chat-Completions-Aufruf mit JSON-Antwort |
Eigenheiten, die man kennen muss:
* Der Schlüssel liegt in der **Scanner**-Datenbank (`settings.openai_api_key`),
die Modellwahl in der **CRM**-Datenbank (`crm_settings.ai_model_*`). Der
Schlüssel stand dort schon immer; die Modellwahl ist CRM-weit.
* Scanner-Dienste (`matchingEngine.js`, `appliedCheck.js`) holen Schlüssel und
Modell ebenfalls hier - keiner liest `openai_api_key` mehr selbst.
* Kein Zwischenspeicher: jede Änderung wirkt beim nächsten Aufruf.
### `server/src/sso/`
Anmeldung über Microsoft Entra, fachlich in
+4 -3
View File
@@ -99,11 +99,11 @@ werden:
Die erweiterte Suche benutzt **denselben OpenAI-Schlüssel wie der Scanner** –
`openai_api_key` in der Scanner-Tabelle `settings`, gepflegt unter
*Einstellungen → Scanner*, ersatzweise `OPENAI_API_KEY` aus der Umgebung. Ein
*Einstellungen → KI* (siehe [14 KI](14-ki.md)), ersatzweise `OPENAI_API_KEY` aus der Umgebung. Ein
zweiter Schlüssel an zweiter Stelle wäre eine Quelle für den Fall „die eine
Funktion geht, die andere nicht, und keiner weiß warum".
Modell: `gpt-4o-mini`, wie im Scanner. Fehlt der Schlüssel, antwortet der
Modell: einstellbar unter *Einstellungen → KI*, Vorgabe `gpt-4o-mini`. Fehlt der Schlüssel, antwortet der
Endpunkt mit `503` und dem Hinweis auf die Einstellungen; die Volltextsuche
bleibt davon unberührt.
@@ -116,7 +116,8 @@ Scanner behält sein SDK, dort hängt mehr daran.
| Datei | Aufgabe |
| --- | --- |
| `server/src/routes/search.js` | beide Endpunkte |
| `server/src/ai.js` | OpenAI-Aufruf, Schlüssel des Scanners |
| `server/src/ai.js` | OpenAI-Aufruf, Schlüssel, Modellwahl |
| `server/src/routes/ai.js` | Einstellungen → KI (siehe [14 KI](14-ki.md)) |
| `server/src/routes/iconCrm.js` | `fetchIconContacts()` samt Zwischenspeicher |
| `src/components/GlobalSearch.tsx` | das Feld, beide Modi, die Trefferliste |
| `src/components/Header.tsx` | bindet es mittig in die Kopfleiste ein |
+102
View File
@@ -0,0 +1,102 @@
# 14 KI: Schlüssel und Modelle
Das CRM ruft OpenAI an drei Stellen auf. Schlüssel und Modell pflegen
Administratoren unter **Einstellungen → KI** - ohne SSH, ohne Neustart.
![Einstellungen, Reiter KI: Schlüssel mit den letzten vier Zeichen, Modell je Einsatzzweck](bilder/einstellungen-ki.png)
## Wo die KI arbeitet
| Einsatzzweck | Code | Schlüssel in `crm_settings` | Vorgabe |
| --- | --- | --- | --- |
| Scanner: Bewertung Lebenslauf gegen Projekt | `scanner/services/matchingEngine.js` | `ai_model_matching` | `gpt-4o-mini` |
| Scanner: Abgleich „bereits beworben" | `scanner/services/appliedCheck.js` | `ai_model_applied` | `gpt-5.6-luna` |
| Erweiterte Suche | `routes/search.js` | `ai_model_search` | `gpt-4o-mini` (bzw. `OPENAI_MODEL`) |
Die Vorgaben sind genau die Modelle, die vor der Einstellung fest im Code
standen. Wer nichts umstellt, merkt also nichts. Steht in `crm_settings` kein
oder ein ungültiger Eintrag, gilt die Vorgabe.
```mermaid
flowchart LR
UI["Einstellungen → KI"] -->|"PUT /api/ai/key"| V{"GET /v1/models<br/>mit neuem Schlüssel"}
V -->|"abgelehnt"| X["Fehlermeldung,<br/>alter Schlüssel bleibt"]
V -->|"ok"| K[("perinco_scanner.settings<br/>openai_api_key")]
UI -->|"PUT /api/ai/models"| M[("perinco_CRM.crm_settings<br/>ai_model_*")]
K --> AI["server/src/ai.js<br/>aiKey() / modelFor()"]
E["OPENAI_API_KEY<br/>server/.env"] -.->|"nur wenn DB leer"| AI
M --> AI
AI --> S1["Scanner-Bewertung"]
AI --> S2["Abgleich bereits beworben"]
AI --> S3["Erweiterte Suche"]
```
## Schlüssel
Es gibt **genau einen** OpenAI-Schlüssel für Scanner und CRM. Er liegt
unverändert als `openai_api_key` in der Scanner-Tabelle `settings` - dort, wo er
schon immer lag. Nichts musste umziehen. Ersatzweise gilt `OPENAI_API_KEY` aus
`server/.env`.
* **Anzeige nur mit Ende.** Die Oberfläche zeigt `sk-…` und die letzten vier
Zeichen, dazu die Quelle (*hier hinterlegt* oder *server/.env*). Mehr gibt der
Server nie heraus, auch nicht an Administratoren. Bei Schlüsseln unter zwölf
Zeichen steht nur *ja*.
* **Vor dem Speichern geprüft.** Ein neuer Schlüssel wird erst gegen
`GET https://api.openai.com/v1/models` ausprobiert. Lehnt OpenAI ab, bleibt der
alte gültig, und die Meldung von OpenAI steht im Fehlertext. Der Abruf kostet
nichts.
* **Verwerfen** leert den hinterlegten Wert. Danach gilt wieder die `.env` -
oder, wenn dort nichts steht, gibt es keinen Schlüssel mehr.
* Der Schlüssel liegt **unverschlüsselt** in der Scanner-Datenbank, wie bisher.
Ein Dump von `perinco_scanner` enthält ihn also.
Im Reiter *Scanner* gibt es kein Schlüsselfeld mehr. `POST /api/scanner/settings`
nimmt `openai_api_key` aus Kompatibilität weiter an.
## Modelle
Das Dropdown füllt sich **live von OpenAI**: alle Modelle, die der Schlüssel
nutzen darf, gefiltert auf Textmodelle (`gpt-*`, `o1`/`o3`/`o4…`, `chatgpt-*`,
ohne Audio, Bild, Embedding, Realtime, TTS, Suche, Moderation). Die aktuell
eingestellten Modelle stehen immer mit drin, die Vorgabe ist markiert.
Ist die Liste nicht abrufbar (kein Schlüssel, OpenAI nicht erreichbar), zeigt
das Dropdown eine feste Auswahl, und die Seite sagt, warum.
Die Wahl gilt ab dem nächsten Aufruf - es gibt keinen Zwischenspeicher.
### Reasoning- und klassische Modelle
Die beiden Modellfamilien nehmen unterschiedliche Parameter. Damit ein Wechsel
im Dropdown keinen `400` von OpenAI auslöst, entscheidet `isReasoningModel()`
(Namen `o1…`, `o3…`, `gpt-5…`) je Aufruf:
| | Reasoning (`o*`, `gpt-5*`) | klassisch (`gpt-4o*`, `gpt-4.1*`) |
| --- | --- | --- |
| Scanner-Bewertung | `reasoning_effort: low` | `temperature: 0.1` |
| Abgleich „bereits beworben" | `reasoning_effort: low` | `temperature: 0` |
| Erweiterte Suche | `max_completion_tokens` | `max_completion_tokens` |
`max_completion_tokens` verstehen beide Familien, das alte `max_tokens` die
Reasoning-Modelle nicht.
## Endpunkte
Alle unter `/api/ai`, nur für Administratoren.
| Methode | Pfad | Zweck |
| --- | --- | --- |
| GET | `/settings` | Schlüsselzustand, Modell je Zweck, Auswahlliste |
| PUT | `/key` | `{ key }` - prüfen, dann speichern |
| DELETE | `/key` | hinterlegten Schlüssel verwerfen |
| PUT | `/models` | `{ matching?, applied?, search? }` - Namen geprüft gegen `^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$` |
## Dateien
| Datei | Aufgabe |
| --- | --- |
| `server/src/ai.js` | Schlüssel, Modellwahl, Modellliste, Aufruf der Suche |
| `server/src/routes/ai.js` | die vier Endpunkte |
| `src/components/settings/AiSettings.tsx` | Reiter *KI* |
| `server/test/ai.test.mjs` | Modellnamen und Reasoning-Erkennung |
+70 -69
View File
@@ -1,69 +1,70 @@
# Dokumentation perinco CRM
Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und
werden von Gitea direkt gerendert.
**Neu hier?** Wer die Anwendung *benutzen* will, fängt bei
[07 Anleitung für Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln*
will, bei [08 Anleitung für Entwickler](08-anleitung-entwickler.md). Beide setzen
keine Vorkenntnisse voraus.
| Kapitel | Inhalt |
| --- | --- |
| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen |
| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme |
| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Rückbau der alten App |
| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus |
| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche |
| [07 Anleitung für Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt für Schritt |
| [08 Anleitung für Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen |
| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte |
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails |
| [12 Globale Suche](12-suche.md) | Volltext und erweiterte Suche im Kopf der Anwendung |
| [13 Anmeldung über Microsoft](13-anmeldung-sso.md) | SSO mit Entra, Zuordnung der Konten, Einrichtung |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md).
## Das Ganze in einem Bild
```mermaid
flowchart TB
subgraph Browser
SPA["React-SPA<br/>Vite-Build"]
WSP["Whisper im Web Worker<br/>WebGPU / WASM"]
end
subgraph Server["Server 212.227.135.41"]
AP["Apache<br/>:443"]
API["Express-Backend<br/>127.0.0.1:5006"]
SCH["Scanner-Scheduler<br/>node-cron"]
MSCH["Mail-Scheduler<br/>node-cron"]
PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")]
end
OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektbörsen<br/>freelancermap, hays, ..."]
GRAPH["Microsoft Graph<br/>crm@perinco.de"]
SPA -->|"HTTPS /api"| AP
AP --> API
API --> DB1
API --> DB2
SCH --> PW
PW --> BOERSE
SCH --> OAI
SCH --> DB2
API -.-> SCH
API -.-> MSCH
MSCH -->|"Delta Query, nur lesend"| GRAPH
MSCH --> DB1
SPA -.->|"Audio bleibt im Browser"| WSP
```
Die ursprüngliche Scanner-App unter `scanner.perinco.de` ist am 09.09.2026
zurückgebaut worden; das CRM ist seither die einzige Instanz.
# Dokumentation perinco CRM
Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und
werden von Gitea direkt gerendert.
**Neu hier?** Wer die Anwendung *benutzen* will, fängt bei
[07 Anleitung für Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln*
will, bei [08 Anleitung für Entwickler](08-anleitung-entwickler.md). Beide setzen
keine Vorkenntnisse voraus.
| Kapitel | Inhalt |
| --- | --- |
| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen |
| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme |
| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Rückbau der alten App |
| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus |
| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche |
| [07 Anleitung für Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt für Schritt |
| [08 Anleitung für Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen |
| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte |
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails |
| [12 Globale Suche](12-suche.md) | Volltext und erweiterte Suche im Kopf der Anwendung |
| [13 Anmeldung über Microsoft](13-anmeldung-sso.md) | SSO mit Entra, Zuordnung der Konten, Einrichtung |
| [14 KI](14-ki.md) | OpenAI-Schlüssel und Modellwahl je Einsatzzweck |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md).
## Das Ganze in einem Bild
```mermaid
flowchart TB
subgraph Browser
SPA["React-SPA<br/>Vite-Build"]
WSP["Whisper im Web Worker<br/>WebGPU / WASM"]
end
subgraph Server["Server 212.227.135.41"]
AP["Apache<br/>:443"]
API["Express-Backend<br/>127.0.0.1:5006"]
SCH["Scanner-Scheduler<br/>node-cron"]
MSCH["Mail-Scheduler<br/>node-cron"]
PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")]
end
OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektbörsen<br/>freelancermap, hays, ..."]
GRAPH["Microsoft Graph<br/>crm@perinco.de"]
SPA -->|"HTTPS /api"| AP
AP --> API
API --> DB1
API --> DB2
SCH --> PW
PW --> BOERSE
SCH --> OAI
SCH --> DB2
API -.-> SCH
API -.-> MSCH
MSCH -->|"Delta Query, nur lesend"| GRAPH
MSCH --> DB1
SPA -.->|"Audio bleibt im Browser"| WSP
```
Die ursprüngliche Scanner-App unter `scanner.perinco.de` ist am 09.09.2026
zurückgebaut worden; das CRM ist seither die einzige Instanz.
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB