Die Sperrfrist nach dem Start begann bei jedem Neustart von vorn. Zwei Deployments im Abstand von zehn Minuten haben den faelligen Suchlauf damit zweimal um eine halbe Stunde verschoben - er fand nie statt. Wer oefter als alle SCANNER_SCHEDULER_OFFSET Minuten deployt, hat einen Scanner, der automatisch nie sucht. Der Zeitpunkt des letzten Laufs liegt jetzt in settings.last_scan_at, von der Pipeline geschrieben (auch nach einem Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt). Der Versatz greift nur noch, solange dort nichts steht - also beim allerersten Start dieser Instanz, wo er gebraucht wird. Der Scheduler liest den Wert zusaetzlich in jedem Tick: ein manueller Scan aus der Oberflaeche schreibt ihn, weiss aber nichts von der Variablen im Scheduler. Ohne den Abgleich konnte der Scheduler direkt hinter einem manuellen Lauf noch einmal losziehen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
663 lines
32 KiB
Markdown
663 lines
32 KiB
Markdown
# perinco CRM
|
|
|
|
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
|
|
Enthaelt den portierten *Projekt Scanner*.
|
|
|
|
> **Technische Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md)
|
|
> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb.
|
|
|
|
## Corporate Identity
|
|
|
|
Direkt aus dem perinco Logo gesampelt:
|
|
|
|
| Rolle | Wert |
|
|
| --- | --- |
|
|
| Primaerfarbe (Gruen) | `#6BA04A`, Akzent `#68A33F` |
|
|
| Sekundaerfarbe (Grau) | `#6E6E6E` |
|
|
| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans |
|
|
|
|
Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`)
|
|
sowie die Flaechen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge).
|
|
|
|
## Dark Mode
|
|
|
|
Umschalter rechts oben im Header. Die Wahl liegt in `localStorage`
|
|
(`perinco_crm_theme`); ohne Wahl folgt die App der Systemeinstellung, ein
|
|
Doppelklick auf den Umschalter stellt das wieder her.
|
|
|
|
Umgesetzt ist der Dark Mode **ohne `dark:`-Varianten im Markup**: die Klasse
|
|
`dark` auf `<html>` tauscht in `src/index.css` nur die Werte der Variablen
|
|
`canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. Die
|
|
Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch.
|
|
|
|
Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewaehlt:
|
|
|
|
| Was | Warum |
|
|
| --- | --- |
|
|
| `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben |
|
|
| `text-white` | steht immer auf farbigen Flaechen (Buttons, Termin-Chips) |
|
|
| `brand-500` | das CI-Gruen der Buttons; nur die Tints darum kippen |
|
|
|
|
Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt
|
|
deshalb `var(--color-*)` direkt. Das Logo ist fuer helle Hintergruende gemacht -
|
|
die Utility `.logo-adaptive` dreht im Dark Mode die Helligkeit und holt per
|
|
`hue-rotate` das Gruen zurueck.
|
|
|
|
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
|
|
blitzt beim Laden die helle Variante auf.
|
|
|
|
## Architektur
|
|
|
|
```
|
|
Browser ──► Apache (:443/:80, pcrm.perinco.de)
|
|
├── / → /var/www/perinco_CRM/dist (SPA, FallbackResource)
|
|
└── /api → 127.0.0.1:5006 (Express)
|
|
├──► MariaDB perinco_CRM (CRM)
|
|
└──► MariaDB project_scanner (Scanner)
|
|
```
|
|
|
|
Der Scanner haengt unter `/api/scanner` und braucht damit **keine** eigene
|
|
Apache-Regel.
|
|
|
|
Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend.
|
|
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber
|
|
den Apache-Proxy erreichbar.
|
|
|
|
### Datenmodell
|
|
|
|
| Tabelle | Inhalt |
|
|
| --- | --- |
|
|
| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) |
|
|
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zustaendig) |
|
|
| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
|
|
| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle |
|
|
| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
|
|
| `crm_events` | Termine des gemeinsamen Teamkalenders; `description` kann Mention-Tokens enthalten |
|
|
| `crm_event_participants` | Teilnehmer je Termin (n:m), Primaerschluessel verhindert Doppeleintraege |
|
|
|
|
### Rollen
|
|
|
|
* `admin` - sieht den Menuepunkt Einstellungen inkl. Nutzerverwaltung
|
|
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusaetzlich mit 403 ab
|
|
|
|
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
|
|
degradiert, deaktiviert oder geloescht wird.
|
|
|
|
Ausnahme von der Admin-Regel ist `GET /api/users/directory`: die Benutzerliste
|
|
brauchen alle Rollen fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions,
|
|
Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie.
|
|
Termine darf jeder anlegen; aendern und loeschen nur der Ersteller oder ein
|
|
Administrator.
|
|
|
|
## Struktur
|
|
|
|
```
|
|
src/
|
|
App.tsx Layout, Datenladen, View-Routing
|
|
types.ts Gemeinsame Typen und Konstanten
|
|
lib/api.ts Fetch-Wrapper + typisierte Endpunkte
|
|
lib/auth.tsx Auth-Context (JWT im localStorage)
|
|
lib/theme.tsx Theme-Context (hell/dunkel/System)
|
|
lib/navigation.tsx Ansichtswechsel fuer tief liegende Komponenten
|
|
lib/whisper.tsx Modelleinstellungen, Worker-Lebenszyklus, Zustand
|
|
lib/whisper.worker.ts Whisper im Web Worker (Laden und Erkennen)
|
|
lib/audio.ts Aufnahme zu 16-kHz-Mono-PCM
|
|
lib/dictation.tsx Aufnahme-Hook (Mikrofon, Dauer, Abbruch)
|
|
lib/imageCompress.ts Bilder im Browser auf 256 px verkleinern
|
|
components/
|
|
Sidebar.tsx Menue links (Opportunities, Kalender, Kontakte,
|
|
Scanner mit aufklappbaren Unterpunkten);
|
|
unten Whisper (alle) und Einstellungen (admin only)
|
|
Header.tsx Titel, Menue-Toggle, Umschalter fuer den Dark Mode
|
|
Toast.tsx, LoginScreen.tsx
|
|
ui/Modal.tsx Modal + Bestaetigungsdialog
|
|
ui/UserAvatar.tsx Benutzerfoto, sonst Initiale auf der Benutzerfarbe
|
|
ui/AvatarPicker.tsx Foto-Upload mit Vorschau (Kontakte und Benutzer)
|
|
ui/DictationField.tsx Mikrofon-Button, Hinweisbox, Textfeld mit Diktat
|
|
whisper/
|
|
WhisperSettings.tsx Reiter Whisper: Geraetecheck, Modellwahl, Probe
|
|
opportunities/
|
|
Opportunities.tsx Suche, Phasenfilter, Sortierung, Ansichtswechsel
|
|
OpportunityGrid.tsx Kachelansicht
|
|
OpportunityList.tsx Sortierbare Tabelle
|
|
OpportunityPipeline.tsx Kanban nach Phase mit Drag & Drop
|
|
OpportunityModal.tsx Formular inkl. Ansprechpartnern und Diktat
|
|
opportunityUi.tsx Phasenfarben, Aufklapptext, Chancenbalken
|
|
calendar/
|
|
Calendar.tsx Monat/Woche/Tag + Liste der naechsten Termine
|
|
EventModal.tsx Terminformular inkl. Teilnehmerauswahl
|
|
EventDetailModal.tsx Leseansicht mit klickbaren @-Mentions
|
|
UserDetailModal.tsx Detailfenster fuer markierte Benutzer
|
|
mentions.tsx Mention-Parser, Renderer, @-Autocomplete
|
|
calendarUtils.ts Datums-, Raster- und Layoutlogik
|
|
leads/
|
|
Leads.tsx Suche, Statusfilter, Ansichtswechsel
|
|
LeadGrid.tsx Kachelansicht
|
|
LeadList.tsx Listenansicht (sortierbar)
|
|
LeadNetwork.tsx Interaktives Netzwerkdiagramm (d3-force + SVG)
|
|
LeadModal.tsx Kontaktformular inkl. Verbindungspflege
|
|
leadUi.tsx Statusfarben, Eingabestile, Avatar
|
|
scanner/
|
|
ScannerDashboard.tsx Kennzahlen, Filter, Match-Tabelle, Signalton
|
|
ScannerRejected.tsx Reiter Unpassend (status = rejected)
|
|
ScannerAgents.tsx Suchagenten inkl. CV-Upload
|
|
ScannerPortals.tsx Boersen-Konten, Cookie-Import, Verbindungstest
|
|
MatchDetailModal.tsx Detailfenster eines Treffers (beide Listen)
|
|
scannerUi.tsx Prozent-Badge, Status-Pillen, Formatierung
|
|
settings/
|
|
Settings.tsx Tabs Nutzerverwaltung / Mein Konto / Scanner
|
|
UserManagement.tsx Benutzer anlegen, bearbeiten, loeschen
|
|
ScannerSettings.tsx Alle Einstellungen des Projekt Scanners
|
|
ColorPicker.tsx Auswahl der persoenlichen Kalenderfarbe
|
|
lib/scannerApi.ts Typisierte Endpunkte unter /api/scanner
|
|
server/
|
|
src/server.js Express-App
|
|
src/db.js Pool + Schema-Init + Admin-Seed
|
|
src/schema.sql Idempotentes Schema
|
|
src/middleware/auth.js JWT-Pruefung, Admin-Guard
|
|
src/userFields.js Pruefung von Farbe und Foto (von auth und users genutzt)
|
|
src/routes/ auth, users, leads, connections, events, opportunities,
|
|
scanner
|
|
src/scanner/ Portierter Projekt Scanner: Sequelize-Modelle,
|
|
Scraper (Playwright), Matching (OpenAI), Scheduler
|
|
deploy/
|
|
perinco-crm-backend.service systemd-Unit
|
|
pcrm.perinco.de.conf Apache-Vhost
|
|
```
|
|
|
|
## Opportunities
|
|
|
|
Erster Reiter im Menue und Startansicht. Drei Ansichten wie bei den Kontakten,
|
|
umgeschaltet oben rechts:
|
|
|
|
| Ansicht | Inhalt |
|
|
| --- | --- |
|
|
| **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und naechstem Schritt |
|
|
| **Liste** | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um |
|
|
| **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar |
|
|
|
|
Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer
|
|
Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen
|
|
mag oder auf dem Tablet arbeitet, aendert die Phase ueber das Auswahlfeld auf der
|
|
Karte.
|
|
|
|
**Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste
|
|
aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in
|
|
Personentagen (halbe Tage moeglich), Projektstart und -ende, letzte Interaktion
|
|
mit Notiz, naechster Schritt, Tags, interne Notizen, Zustaendigkeit sowie
|
|
beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle.
|
|
|
|
**Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusaetzlich
|
|
mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe
|
|
ueber der Liste, je Pipeline-Spalte und in der Kopfzeile.
|
|
|
|
**Sortieren** geht doppelt: ueber das Auswahlfeld in der Steuerleiste (wirkt in
|
|
allen Ansichten) und in der Liste ueber die Spaltenkoepfe. Geld- und Datumsfelder
|
|
starten absteigend, Text aufsteigend; leere Werte landen immer hinten.
|
|
|
|
Aendern und loeschen darf der Ersteller, der Zustaendige und jeder Administrator.
|
|
|
|
## Projekt Scanner
|
|
|
|
Der eigenstaendige *Projekt Scanner* ist vollstaendig hier eingezogen und soll
|
|
die alte App ersetzen. Die laeuft weiter unter `scanner.perinco.de`:
|
|
|
|
```
|
|
/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005
|
|
/var/www/Projekt Scanner/frontend/dist DocumentRoot des Vhosts
|
|
systemd-Unit: project-scanner-backend (nicht "project-scanner")
|
|
```
|
|
|
|
Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene
|
|
Datenbank, eigener Verschluesselungsschluessel, eigenes Upload-Verzeichnis,
|
|
eigener Scheduler. Die alte App bleibt unveraendert - weder ihre Dateien noch
|
|
ihre Datenbank werden angefasst.
|
|
|
|
Menuepunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter
|
|
auf:
|
|
|
|
| Reiter | Inhalt |
|
|
| --- | --- |
|
|
| **Dashboard** | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, *Jetzt scannen* |
|
|
| **Unpassend** | als „passt nicht" markierte Treffer; zurueckholen oder loeschen |
|
|
| **Suchagenten** | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv |
|
|
| **Boersen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest |
|
|
|
|
Die **Einstellungen** des Scanners stehen nicht im Untermenue, sondern als Reiter
|
|
*Scanner* unter *Einstellungen* - also nur fuer Rolle `admin`. Die Reiter selbst
|
|
sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an
|
|
beiden Stellen aendern; es ist derselbe Wert in der Datenbank.
|
|
|
|
### Was portiert wurde
|
|
|
|
| Teil | Wohin |
|
|
| --- | --- |
|
|
| Sequelize-Modelle (`agents`, `portals`, `projects`, `matches`, `settings`) | `server/src/scanner/models/` |
|
|
| Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` |
|
|
| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` |
|
|
| Oberflaeche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
|
|
| Uebernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
|
|
|
|
Drei bewusste Unterschiede zum Original:
|
|
|
|
1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfaellt - alle Endpunkte
|
|
liegen hinter `requireAuth`, also der CRM-Sitzung.
|
|
2. **CV-PDFs** kommen ueber `GET /api/scanner/agents/:id/cv` statt aus einem
|
|
statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token
|
|
mitschicken, deshalb holt das Frontend die Datei und oeffnet sie als Blob.
|
|
3. **Meldungen** laufen als Toast statt `alert()`, Loeschabfragen als Dialog.
|
|
|
|
### Parallelbetrieb - alles doppelt
|
|
|
|
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu beruehren.
|
|
Dafuer ist hier jede geteilte Ressource verdoppelt:
|
|
|
|
| | alte App | diese Instanz |
|
|
| --- | --- | --- |
|
|
| Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) |
|
|
| Verschluesselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
|
|
| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` |
|
|
| Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` |
|
|
| Aufraeumen | 03:00 | 03:30 |
|
|
|
|
Die Scanner-Datenbank legt das Backend beim Start **selbst an**
|
|
(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`,
|
|
Portale und Standardeinstellungen werden geseedet. Es genuegt also, das Backend
|
|
zu starten.
|
|
|
|
| Variable | Bedeutung |
|
|
| --- | --- |
|
|
| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen |
|
|
| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
|
|
| `SCANNER_ENCRYPTION_KEY` | eigener Schluessel fuer die Portal-Passwoerter; ohne ihn wird ein bekannter Ersatzschluessel benutzt und beim Start gewarnt |
|
|
| `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `<server>/uploads` |
|
|
| `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab |
|
|
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den **allerersten** Lauf, Standard `30`. Danach traegt `settings.last_scan_at` den Zeitplan ueber Neustarts hinweg |
|
|
|
|
> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhaengig
|
|
> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App
|
|
> laeuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen
|
|
> will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert
|
|
> dann weiter.
|
|
|
|
Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben
|
|
Portal-Sessions (aus derselben Cookie-Quelle) und wuerden beim ersten Start
|
|
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei
|
|
Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den
|
|
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschraenkt.
|
|
|
|
Ab dem zweiten Start traegt `settings.last_scan_at` den Zeitplan - die Frist
|
|
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
|
|
faelliger Lauf konnte beliebig lange ausfallen (siehe
|
|
[docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)).
|
|
|
|
Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler
|
|
und *Jetzt scannen* teilen sich eine Sperre (`isPipelineRunning`), ein zweiter
|
|
Aufruf bekommt 409 und den Hinweis *„Ein Suchlauf läuft bereits"*. Das Dashboard
|
|
zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche,
|
|
pausiert, Suchlauf läuft - samt Namen der Datenbank.
|
|
|
|
### Daten aus der alten App uebernehmen
|
|
|
|
Damit die neue Instanz nicht bei Null anfaengt, holt ein Skript Agenten, Portale
|
|
und Einstellungen herueber. Es liest die alte Datenbank nur, geschrieben wird
|
|
ausschliesslich in `perinco_scanner`:
|
|
|
|
```bash
|
|
cd /var/www/perinco_CRM/server
|
|
npm run scanner:import -- --dry-run # nur anzeigen
|
|
npm run scanner:import # Einstellungen, Portale, Agenten + CVs
|
|
npm run scanner:import -- --with-matches # zusaetzlich Projekte und Treffer
|
|
npm run scanner:import -- --force # vorhandene Zieldaten vorher loeschen
|
|
```
|
|
|
|
Quelle kommt aus `LEGACY_*` in `server/.env`:
|
|
|
|
| Variable | Wofuer |
|
|
| --- | --- |
|
|
| `LEGACY_DB_NAME` | Standard `project_scanner` |
|
|
| `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
|
|
| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anfuehrungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen |
|
|
| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwoerter uebernehmen: entschluesseln mit dem alten, neu verschluesseln mit dem eigenen Schluessel |
|
|
|
|
Uebernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der
|
|
DB) - der neue Scanner ist damit sofort scharf.
|
|
|
|
Agenten, Projekte und Treffer behalten ihre Ids, damit uebernommene Treffer
|
|
weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript
|
|
ab, wenn im Ziel schon Agenten stehen - `--force` raeumt vorher auf.
|
|
|
|
> **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank
|
|
> gespeicherten Portal-Passwoerter lassen sich mit dem dortigen
|
|
> `ENCRYPTION_KEY` **nicht** mehr entschluesseln - der Schluessel wurde nach dem
|
|
> Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging
|
|
> also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die
|
|
> Passwoerter unter *Boersen-Konten* neu ein. Die Sessions (Cookies) sind davon
|
|
> unberuehrt und wurden uebernommen.
|
|
|
|
### Playwright
|
|
|
|
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende
|
|
Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits
|
|
in `/root/.cache/ms-playwright`, also faellt kein zweiter Browser-Download an und
|
|
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der
|
|
Version wuerde beides aufgeben, deshalb steht in `server/package.json` ein
|
|
`comments`-Feld mit dieser Begruendung.
|
|
|
|
## Diktat (Whisper im Browser)
|
|
|
|
Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
|
|
naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
|
|
laeuft **vollstaendig im Browser des Benutzers**: Whisper als ONNX-Modell ueber
|
|
`@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder
|
|
ersatzweise auf der CPU (WASM).
|
|
|
|
```
|
|
MediaRecorder ──► AudioContext (16 kHz Mono) ──► Web Worker ──► Whisper (WebGPU/WASM)
|
|
```
|
|
|
|
Das Backend ist daran **nicht beteiligt** und sieht keine Audiodaten - es gibt
|
|
keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen
|
|
werden nirgends gespeichert.
|
|
|
|
* Die Erkennung laeuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
|
|
damit die Oberflaeche waehrenddessen nicht einfriert.
|
|
* Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die
|
|
**Modelldateien** kommen beim ersten Laden vom Hugging-Face-CDN und bleiben
|
|
danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den
|
|
eigenen Server legen und `env.localModelPath` setzen.
|
|
|
|
### Reiter "Whisper"
|
|
|
|
Unten im Menue, ueber den Einstellungen, fuer **alle Rollen** sichtbar. Es gibt
|
|
dort **nichts zu konfigurieren** - genau ein Knopf:
|
|
|
|
1. Reiter oeffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."*
|
|
2. **Sprachmodell laden (299 MB)** klicken, Fortschrittsbalken abwarten
|
|
3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch
|
|
nach jeder weiteren Anmeldung
|
|
|
|
Dazu gibt es eine Probeaufnahme und **Sprachmodell loeschen**, falls man den
|
|
Platz zurueckhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
|
|
kein Rechenweg, keine Geraetetabelle.
|
|
|
|
Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als
|
|
Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden,
|
|
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
|
|
kaputt (siehe unten); eine Auswahl haette also nur Gelegenheit geboten, etwas
|
|
falsch zu machen.
|
|
|
|
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
|
|
diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der
|
|
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht,
|
|
bekommt es nicht sofort wieder heruntergeladen.
|
|
|
|
### Wo das Modell liegt und wie lange
|
|
|
|
Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im
|
|
**Cache-Speicher der Seite** (`caches`, Name `transformers-cache`), der
|
|
Sitzungen und Browserneustarts ueberlebt. Beim Laden bittet die App zusaetzlich
|
|
per `navigator.storage.persist()` um dauerhaften Speicher; damit darf der Browser
|
|
die Dateien bei Platznot nicht mehr selbst wegraeumen. Chrome entscheidet darueber
|
|
nach eigenen Kriterien und lehnt haeufig ab - dann funktioniert alles genauso, nur
|
|
ohne diese Zusicherung.
|
|
|
|
Geraet oder Browserprofil gewechselt? Dann faellt der Download erneut an; der
|
|
Speicher gehoert zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
|
|
nach dem Laden nicht im Cache, sagt sie es ausdruecklich.
|
|
|
|
### Warum small und q4 - die Messungen
|
|
|
|
Die folgenden Zahlen sind der Grund, warum es keine Auswahl gibt. Gemessen mit
|
|
derselben deutschen Testaufnahme (Windows-Sprachausgabe, also schwieriger als
|
|
eine echte Stimme) auf einem Rechner mit NVIDIA-GPU, jeweils in q4:
|
|
|
|
| Modell | Erkennung des Satzes | Download | Laden |
|
|
| --- | --- | --- | --- |
|
|
| tiny | "auf es vier Hanna", "Der Tag ist satscht bei L-10 Euro" | 99 MB | 4 s |
|
|
| small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s |
|
|
| **large-v3-turbo** | **"auf S4 HANA"**, "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s |
|
|
|
|
Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geraeten ohne WebGPU
|
|
verschiebt sich das Tempo erheblich.
|
|
|
|
Festgelegt ist **large-v3-turbo**: nur dieses Modell bekommt Fachbegriffe wie
|
|
"S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB
|
|
fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der
|
|
Seite und ueberlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne
|
|
jeden Download bereit).
|
|
|
|
`small` ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht
|
|
zwischengespeichert (siehe Groessengrenzen unten) und laedt darum bei jedem Start
|
|
erneut 759 MB.
|
|
|
|
### Welche Genauigkeit auf welchem Rechenweg
|
|
|
|
Deshalb ist `q4` festgelegt:
|
|
|
|
| | q4 | q8 | fp16 | fp32 |
|
|
| --- | --- | --- | --- | --- |
|
|
| **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny laeuft, small blockiert den Tab | Einzeldatei zu gross |
|
|
| **WASM** | laeuft | small 10,3 s | ungetestet | Einzeldatei zu gross |
|
|
|
|
Der wichtigste Grund fuer diese Beschraenkung: bei `small` in fp16 auf WebGPU
|
|
reagiert der ganze Browser-Tab nicht mehr - **kein Timeout in der Seite kann das
|
|
abfangen**, weil das Skript selbst nicht mehr laeuft.
|
|
|
|
Genau deshalb ist die Auswahl verschwunden: `q4` steht fest, und damit kann diese
|
|
Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro
|
|
Erkennung und eine Haenger-Erkennung beim Laden - falls ein Geraet trotzdem etwas
|
|
Unerwartetes tut.
|
|
|
|
Zusaetzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
|
|
hoechstens 5 min). Laeuft sie ab, wird der Worker verworfen und der Fehler
|
|
gemeldet, statt dauerhaft "Erkenne..." zu zeigen.
|
|
|
|
### Die Groessengrenze des Browsers
|
|
|
|
Nicht die Gesamtgroesse entscheidet, sondern die **groesste Einzeldatei** - der
|
|
Browser muss sie in einem zusammenhaengenden Speicherblock halten. Ueber etwa
|
|
500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB
|
|
(turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern.
|
|
|
|
Genau daran scheitern fuer turbo alle Genauigkeiten ausser q4 - ein weiterer
|
|
Grund, warum es keine Auswahl gibt.
|
|
|
|
Die Downloadgroesse im Reiter kommt live von Hugging Face
|
|
(`src/lib/whisperModels.ts`), damit dort eine echte Zahl steht.
|
|
|
|
> **Korrektur:** In einer frueheren Fassung stand hier eine zweite Grenze von
|
|
> etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht
|
|
> zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung.
|
|
> Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht,
|
|
> ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit
|
|
> 762 MB im Cache und ueberlebt einen Browserneustart. Deshalb prueft
|
|
> `scratchpad/realchrome.mjs` die Persistenz gegen ein normal gestartetes
|
|
> Chrome und nicht gegen den Automatisierungskontext.
|
|
|
|
> **Version festgehalten:** `@huggingface/transformers` ist auf `^3.8.1`
|
|
> gepinnt. Version 4.2.0 zieht eine andere ONNX-Laufzeit (`asyncify` statt
|
|
> `jsep`, letztere ist die WebGPU-Variante) - vor einem Wechsel also erst die
|
|
> Modelle real gegentesten.
|
|
|
|
### Wenn das Modell fehlt
|
|
|
|
Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit
|
|
Knopf **Zu Whisper**; die einzelnen Mikrofon-Buttons werden zu *Diktat
|
|
einrichten*. Beides schliesst den Dialog und springt in den Reiter. Auf Geraeten
|
|
ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat.
|
|
|
|
## Kalender
|
|
|
|
Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf
|
|
Eintraege anlegen.
|
|
|
|
* **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blaettern. Klick auf
|
|
einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf
|
|
einen Wochentagskopf springt in die Tagesansicht.
|
|
* **Farben** Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus
|
|
`crm_users.color`; ist sie leer, wird sie stabil aus der Benutzer-ID
|
|
abgeleitet (Palette in `src/types.ts`). Aenderbar unter *Einstellungen → Mein
|
|
Konto* und - fuer Admins fuer alle - in der Nutzerverwaltung. Die Legende
|
|
unter dem Kalender zeigt, welche Farbe zu wem gehoert.
|
|
* **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen
|
|
mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer
|
|
Kalenderfarbe - und sind anklickbar.
|
|
* **@-Mentions** In der Beschreibung `@` tippen, um Kontakte *und* Benutzer zu
|
|
markieren (Pfeiltasten, Enter/Tab zum Uebernehmen). Gespeichert wird ein Token
|
|
`@[Anzeigename](lead:12)` bzw. `@[Anzeigename](user:3)`. In der Leseansicht
|
|
sind Mentions klickbar: Kontakte oeffnen das Kontaktfenster, Benutzer ein
|
|
Detailfenster mit Farbe, naechsten Terminen und zugeordneten Kontakten.
|
|
Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen.
|
|
* **Naechste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
|
|
zeitlich sortiert - der naechste anstehende Termin steht oben. Vergangene
|
|
Termine fallen aus der Liste, bleiben im Kalender aber erhalten.
|
|
|
|
## Fotos
|
|
|
|
Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank
|
|
(`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufblaeht,
|
|
verkleinert der Browser jedes Bild **vor** dem Upload
|
|
(`src/lib/imageCompress.ts`):
|
|
|
|
* mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht,
|
|
die groesste ist 80 px
|
|
* WebP mit Qualitaet 0,82, Fallback JPEG, dann PNG
|
|
* aus einem 2,9-MB-Foto werden so einige Kilobyte
|
|
|
|
Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die
|
|
Laenge; die Quelldatei darf bis 25 MB gross sein, weil sie ohnehin
|
|
komprimiert wird.
|
|
|
|
Benutzerfotos pflegt jeder fuer sich unter *Einstellungen → Mein
|
|
Konto*, Admins fuer alle in der Nutzerverwaltung. Angezeigt werden sie in der
|
|
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
|
|
@-Vorschlaegen, in der Nutzerverwaltung und als Benutzerknoten im
|
|
Netzwerkdiagramm. Ohne Foto erscheint ueberall die Initiale auf der
|
|
Kalenderfarbe (`UserAvatar`).
|
|
|
|
Ueberlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtaegige
|
|
Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die
|
|
aktuelle Uhrzeit.
|
|
|
|
## Netzwerkansicht
|
|
|
|
Kraftgerichtetes Layout (`d3-force`), gerendert als SVG.
|
|
|
|
* **Kreise** = Kontakte, Farbe nach Status, Groesse nach Anzahl Verbindungen
|
|
* **Quadrate** = Benutzer, die Kontakte angelegt haben
|
|
* **Gruene Linie** = Kontakt ↔ Kontakt (Dicke nach Verbindungsstaerke)
|
|
* **Graue gestrichelte Linie** = Ersteller → Kontakt
|
|
|
|
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor,
|
|
Knoten ziehen fixiert ihn (gruener Punkt), Doppelklick loest die Fixierung.
|
|
Ueber *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
|
|
Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
|
|
|
|
## Lokale Entwicklung
|
|
|
|
```bash
|
|
npm install
|
|
cd server && npm install && cp .env.example .env # Werte anpassen
|
|
npm start # Backend :5006
|
|
cd .. && npm run dev # Frontend :3100, /api wird geproxied
|
|
```
|
|
|
|
Fuer lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank
|
|
`perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die
|
|
Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive
|
|
Tabellen, Portalen und Standardeinstellungen.
|
|
|
|
## Deployment
|
|
|
|
Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen -
|
|
der Server hat wenig freien RAM und volles Swap.
|
|
|
|
```powershell
|
|
npm run build
|
|
powershell -File deploy/deploy.ps1 # laedt dist/ und server/src hoch
|
|
powershell -File deploy/deploy.ps1 -Deps # zusaetzlich npm install auf dem Server
|
|
```
|
|
|
|
Ein Rollout mit neuen Backend-Paketen braucht `-Deps`.
|
|
|
|
### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt)
|
|
|
|
```bash
|
|
# 1. .env ergaenzen: erzeugt SCANNER_ENCRYPTION_KEY, holt die LEGACY_*-Werte
|
|
# aus "/var/www/Projekt Scanner/backend/.env", legt vorher ein .env.bak an
|
|
bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh
|
|
|
|
# 2. Bestand uebernehmen
|
|
cd /var/www/perinco_CRM/server
|
|
npm run scanner:import -- --dry-run
|
|
npm run scanner:import -- --with-matches
|
|
|
|
# 3. MemoryMax anheben und neu starten
|
|
cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/
|
|
systemctl daemon-reload && systemctl restart perinco-crm-backend
|
|
journalctl -u perinco-crm-backend -n 40
|
|
```
|
|
|
|
Die Datenbank `perinco_scanner` muss **nicht** von Hand angelegt werden, das
|
|
erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe
|
|
Abschnitt *Playwright* oben.
|
|
|
|
> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten
|
|
> App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die
|
|
> Aenderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb
|
|
> direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im
|
|
> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos).
|
|
> Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
|
|
|
|
Das hoehere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
|
|
Chromium als Kindprozess in derselben cgroup, mit 512M wuerde der OOM-Killer das
|
|
Backend mitten im Scrapen beenden. `deploy.ps1` laedt die Unit nicht mit hoch -
|
|
daher der `cp` oben.
|
|
|
|
> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner
|
|
> gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET`
|
|
> verschiebt die Laeufe gegeneinander, garantiert aber nichts - der alte Dienst
|
|
> hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap
|
|
> ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht.
|
|
|
|
Manuell entspricht das:
|
|
|
|
```powershell
|
|
pscp -r -P 4345 dist root@212.227.135.41:/var/www/perinco_CRM/
|
|
pscp -r -P 4345 server\src root@212.227.135.41:/var/www/perinco_CRM/server/
|
|
pscp -P 4345 server\package.json root@212.227.135.41:/var/www/perinco_CRM/server/
|
|
plink -P 4345 root@212.227.135.41 "systemctl restart perinco-crm-backend"
|
|
```
|
|
|
|
### Serverseitige Einrichtung (einmalig, bereits erledigt)
|
|
|
|
```bash
|
|
mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS perinco_CRM
|
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
mkdir -p /var/www/perinco_CRM/server
|
|
# .env anlegen (chmod 600), Abhaengigkeiten installieren
|
|
cd /var/www/perinco_CRM/server && npm install --omit=dev
|
|
cp deploy/perinco-crm-backend.service /etc/systemd/system/
|
|
systemctl daemon-reload && systemctl enable --now perinco-crm-backend
|
|
cp deploy/pcrm.perinco.de.conf /etc/apache2/sites-available/
|
|
a2ensite pcrm.perinco.de && apache2ctl configtest && systemctl reload apache2
|
|
```
|
|
|
|
### HTTPS (offen - braucht DNS)
|
|
|
|
Sobald `pcrm.perinco.de` per A-Record auf `212.227.135.41` zeigt:
|
|
|
|
```bash
|
|
certbot --apache -d pcrm.perinco.de
|
|
```
|
|
|
|
certbot legt den `:443`-Block selbst an. Bei anderer Subdomain den `ServerName`
|
|
in `deploy/pcrm.perinco.de.conf` und den certbot-Aufruf anpassen.
|
|
|
|
## Zugang
|
|
|
|
Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in
|
|
`server/.env` angelegt - aber nur, wenn `crm_users` leer ist. Das Passwort ist
|
|
danach ausschliesslich ueber die Nutzerverwaltung bzw. *Mein Konto* aenderbar.
|