feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# 01 Architektur
|
||||
|
||||
## Bausteine
|
||||
|
||||
Drei Teile, keiner davon spricht direkt mit einem anderen ausser ueber HTTP bzw.
|
||||
SQL:
|
||||
|
||||
| Teil | Technik | Wo |
|
||||
| --- | --- | --- |
|
||||
| Frontend | React 19, TypeScript, Tailwind 4, Vite | statisches Build in `/var/www/perinco_CRM/dist` |
|
||||
| Backend | Express 4, mysql2 (CRM), Sequelize (Scanner) | `/var/www/perinco_CRM/server`, `127.0.0.1:5006` |
|
||||
| Datenbank | MariaDB 10.11 | `perinco_CRM` und `perinco_scanner` |
|
||||
|
||||
Der Browser spricht **nie** direkt mit MariaDB - deshalb ueberhaupt ein Backend.
|
||||
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber
|
||||
den Apache-Reverse-Proxy erreichbar.
|
||||
|
||||
## Topologie
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U(("Browser")) -->|"HTTPS"| A["Apache :443<br/>pcrm.perinco.de"]
|
||||
A -->|"/"| D["dist/<br/>SPA, FallbackResource"]
|
||||
A -->|"/api"| E["Express :5006"]
|
||||
E --> C[("perinco_CRM")]
|
||||
E --> S[("perinco_scanner")]
|
||||
|
||||
U2(("Browser")) -->|"HTTPS"| A2["Apache :443<br/>scanner.perinco.de"]
|
||||
A2 -->|"/api, /uploads, /health"| E2["alte Scanner-App :5005"]
|
||||
E2 --> S2[("project_scanner")]
|
||||
|
||||
classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3
|
||||
class U2,A2,E2,S2 alt
|
||||
```
|
||||
|
||||
Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene
|
||||
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy.
|
||||
|
||||
## Anfrageweg mit Anmeldung
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor B as Browser
|
||||
participant A as Apache
|
||||
participant E as Express
|
||||
participant M as MariaDB
|
||||
|
||||
B->>A: POST /api/auth/login
|
||||
A->>E: weiterleiten
|
||||
E->>M: SELECT crm_users WHERE username
|
||||
M-->>E: Zeile mit bcrypt-Hash
|
||||
E->>E: bcrypt.compare
|
||||
E-->>B: { token (JWT, 12 h), user }
|
||||
Note over B: Token im localStorage
|
||||
|
||||
B->>A: GET /api/scanner/matches<br/>Authorization: Bearer ...
|
||||
A->>E: weiterleiten
|
||||
E->>E: requireAuth: JWT pruefen
|
||||
E->>M: SELECT crm_users WHERE id (ist der noch aktiv?)
|
||||
E->>M: SELECT matches JOIN agents, projects
|
||||
M-->>E: Treffer
|
||||
E-->>B: JSON
|
||||
```
|
||||
|
||||
Bei `401` loescht das Frontend das Token und springt zurueck auf den
|
||||
Anmeldeschirm (`setUnauthorizedHandler` in `src/lib/api.ts`). Das Token wird bei
|
||||
jeder Anfrage neu gegen die Datenbank geprueft - ein deaktivierter Benutzer ist
|
||||
sofort draussen, ohne auf den Ablauf des Tokens zu warten.
|
||||
|
||||
## Rollen
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
L["Anmeldung"] --> R{"Rolle"}
|
||||
R -->|admin| A["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"]
|
||||
R -->|user| U["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
|
||||
```
|
||||
|
||||
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt
|
||||
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`).
|
||||
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
|
||||
fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und
|
||||
Kalenderfarben. Passwort-Hashes liefert der Endpunkt nie.
|
||||
|
||||
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
|
||||
degradiert, deaktiviert oder geloescht wird.
|
||||
|
||||
## Belegte Ports auf dem Server
|
||||
|
||||
Der Rechner hostet weitere Dienste (n8n, Docker, ~15 Apache-Vhosts). Vor jeder
|
||||
Portbelegung `ss -tlnp` pruefen. Relevant hier:
|
||||
|
||||
| Port | Dienst |
|
||||
| --- | --- |
|
||||
| 5006 | perinco CRM Backend (nur `127.0.0.1`) |
|
||||
| 5005 | alte Scanner-App (`0.0.0.0`) |
|
||||
| 3000 | weitere Vite-App |
|
||||
| 5678 | n8n |
|
||||
@@ -0,0 +1,183 @@
|
||||
# 02 Datenmodell
|
||||
|
||||
Zwei Datenbanken, absichtlich getrennt:
|
||||
|
||||
| Datenbank | Inhalt | Zugriff |
|
||||
| --- | --- | --- |
|
||||
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine | `mysql2` direkt, Schema aus `server/src/schema.sql` |
|
||||
| `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) |
|
||||
|
||||
Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Uebernahme aus
|
||||
der eigenstaendigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer
|
||||
eigenen Datenbank, damit beide Anwendungen parallel laufen koennen.
|
||||
|
||||
## perinco_CRM
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
crm_users ||--o{ crm_leads : "owner_id / created_by"
|
||||
crm_users ||--o{ crm_opportunities : "owner_id / created_by"
|
||||
crm_users ||--o{ crm_events : created_by
|
||||
crm_users ||--o{ crm_event_participants : nimmt_teil
|
||||
crm_events ||--o{ crm_event_participants : hat
|
||||
crm_leads ||--o{ crm_lead_connections : from_lead_id
|
||||
crm_leads ||--o{ crm_opportunity_contacts : ist
|
||||
crm_opportunities ||--o{ crm_opportunity_contacts : hat
|
||||
|
||||
crm_users {
|
||||
int id PK
|
||||
varchar username UK
|
||||
varchar email UK
|
||||
varchar name
|
||||
enum role "admin | user"
|
||||
varchar color "Kalenderfarbe, NULL = aus Id abgeleitet"
|
||||
longtext avatar "Data-URL"
|
||||
varchar password_hash "bcrypt"
|
||||
tinyint is_active
|
||||
}
|
||||
crm_leads {
|
||||
int id PK
|
||||
varchar first_name
|
||||
varchar last_name
|
||||
varchar company
|
||||
enum status "Neu .. Verloren"
|
||||
longtext tags "JSON-Array als Text"
|
||||
longtext avatar
|
||||
int owner_id FK
|
||||
int created_by FK
|
||||
}
|
||||
crm_lead_connections {
|
||||
int id PK
|
||||
int from_lead_id FK
|
||||
int to_lead_id FK
|
||||
enum type "Kollege .. Sonstige"
|
||||
tinyint strength "1 lose, 2 normal, 3 eng"
|
||||
}
|
||||
crm_opportunities {
|
||||
int id PK
|
||||
varchar title
|
||||
enum stage "Neu .. Verloren"
|
||||
tinyint probability "0-100"
|
||||
decimal daily_rate
|
||||
decimal duration_days "Personentage, halbe moeglich"
|
||||
date last_interaction_at
|
||||
varchar next_step
|
||||
int owner_id FK
|
||||
}
|
||||
crm_opportunity_contacts {
|
||||
int opportunity_id PK,FK
|
||||
int lead_id PK,FK
|
||||
varchar role "z.B. Entscheider"
|
||||
}
|
||||
crm_events {
|
||||
int id PK
|
||||
varchar title
|
||||
text description "kann @[Name](lead:12) enthalten"
|
||||
datetime starts_at
|
||||
datetime ends_at
|
||||
tinyint all_day
|
||||
int created_by FK
|
||||
}
|
||||
crm_event_participants {
|
||||
int event_id PK,FK
|
||||
int user_id PK,FK
|
||||
}
|
||||
```
|
||||
|
||||
Drei Eigenheiten, die man kennen muss:
|
||||
|
||||
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren
|
||||
MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert.
|
||||
- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in
|
||||
`from_lead_id`). Der Unique-Key verhindert damit auch die Umkehrung als
|
||||
Duplikat, obwohl die Verbindung ungerichtet gedacht ist.
|
||||
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes
|
||||
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
|
||||
wenige Kilobyte.
|
||||
|
||||
## perinco_scanner
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
agents ||--o{ matches : bewertet_fuer
|
||||
projects ||--o{ matches : bewertet
|
||||
portals
|
||||
|
||||
agents {
|
||||
int id PK
|
||||
varchar name
|
||||
varchar email "fuer Benachrichtigungen"
|
||||
longtext cv_text "aus dem PDF extrahiert"
|
||||
varchar cv_file_path "Pfad im Upload-Verzeichnis"
|
||||
varchar keywords "kommagetrennt, Suchbegriffe"
|
||||
text exclude_keywords "kommagetrennt, Ausschluss"
|
||||
tinyint is_active
|
||||
}
|
||||
projects {
|
||||
int id PK
|
||||
varchar portal "freelancermap.de, hays.de, ..."
|
||||
varchar portal_project_id "Id der Boerse"
|
||||
varchar title
|
||||
longtext description
|
||||
varchar location
|
||||
varchar rate
|
||||
varchar url
|
||||
datetime published_at
|
||||
timestamp created_at "gescrapt am, Aufbewahrung 7 Tage"
|
||||
}
|
||||
matches {
|
||||
int id PK
|
||||
int agent_id FK
|
||||
int project_id FK
|
||||
int match_percentage "0-100, von OpenAI"
|
||||
text pros "JSON-Array"
|
||||
text cons "JSON-Array"
|
||||
varchar status "new | reviewed | applied | rejected"
|
||||
tinyint notification_sent
|
||||
}
|
||||
portals {
|
||||
int id PK
|
||||
varchar name UK
|
||||
varchar username
|
||||
varchar password_encrypted "AES, SCANNER_ENCRYPTION_KEY"
|
||||
longtext session_state "Playwright storageState als JSON"
|
||||
varchar connection_status "connected | expired | error | disconnected"
|
||||
datetime last_login_attempt
|
||||
}
|
||||
settings {
|
||||
int id PK
|
||||
varchar key_name UK
|
||||
text value "alles als Text, auch Zahlen und Schalter"
|
||||
}
|
||||
```
|
||||
|
||||
- **`matches` hat einen Unique-Key auf `(agent_id, project_id)`.** Dadurch wird
|
||||
jedes Projekt pro Agent genau einmal von OpenAI bewertet - das ist der Grund,
|
||||
warum ein zweiter Scan-Lauf ueber dieselben Projekte nichts kostet.
|
||||
- **`projects` wird nach 7 Tagen aufgeraeumt** (`cleanupOldProjects`), die
|
||||
Treffer verschwinden per `ON DELETE CASCADE` mit.
|
||||
- **`settings` ist ein Schluessel-Wert-Speicher.** Geheimnisse (`openai_api_key`,
|
||||
`smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder
|
||||
maskiertes Feld beim Speichern laesst den gespeicherten Wert unberuehrt.
|
||||
|
||||
## Statuswege
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> new : Treffer wird angelegt
|
||||
new --> reviewed : "Als Gesehen markieren"
|
||||
new --> applied : "Als Beworben markieren"
|
||||
reviewed --> applied
|
||||
applied --> new : "Zurueck auf Neu"
|
||||
reviewed --> new
|
||||
new --> rejected : "Passt nicht"
|
||||
reviewed --> rejected
|
||||
applied --> rejected
|
||||
rejected --> new : "Zurueck ins Dashboard"
|
||||
rejected --> [*] : loeschen
|
||||
new --> [*] : loeschen
|
||||
```
|
||||
|
||||
`rejected` ist der einzige Status, der aus dem Dashboard verschwindet und
|
||||
stattdessen im Reiter *Unpassend* auftaucht. Solche Treffer zaehlen auch in den
|
||||
Kennzahlen nicht mit.
|
||||
@@ -0,0 +1,184 @@
|
||||
# 03 Projekt Scanner
|
||||
|
||||
Der Scanner durchsucht Projektboersen nach Auftraegen und bewertet jeden Fund mit
|
||||
OpenAI gegen die Lebenslaeufe der Berater. Er ist eine vollstaendige Portierung
|
||||
der eigenstaendigen App und laeuft parallel zu ihr.
|
||||
|
||||
## Ein Suchlauf
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as node-cron<br/>jede Minute
|
||||
participant P as Pipeline
|
||||
participant PW as Playwright
|
||||
participant B as Projektboersen
|
||||
participant DB as perinco_scanner
|
||||
participant AI as OpenAI gpt-4o-mini
|
||||
participant M as SMTP
|
||||
|
||||
C->>P: faellig? (Intervall, Mo-Fr, 07-22 Uhr, Sperrfrist)
|
||||
P->>P: laeuft schon einer? dann abbrechen
|
||||
P->>DB: aktive Agenten laden
|
||||
P->>P: Keywords aller Agenten zusammenfuehren
|
||||
P->>DB: Portal-Sessions laden
|
||||
|
||||
loop je Portal
|
||||
P->>PW: Chromium starten, Cookies setzen
|
||||
PW->>B: Suchseite je Keyword
|
||||
B-->>PW: Projektkarten
|
||||
PW-->>P: Projekte (Titel, Beschreibung, Ort, Rate, URL)
|
||||
end
|
||||
|
||||
loop je Projekt x Agent
|
||||
P->>DB: Treffer schon vorhanden? dann weiter
|
||||
P->>P: Ausschlussbegriff im Text? dann weiter
|
||||
P->>AI: Lebenslauf vs. Projektbeschreibung
|
||||
AI-->>P: { matchPercentage, pros[], cons[] }
|
||||
P->>DB: Treffer speichern
|
||||
alt Prozent >= Benachrichtigungs-Schwelle
|
||||
P->>M: E-Mail an Agent oder Standardempfaenger
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene
|
||||
Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein
|
||||
Projekt, das „Arbeitnehmerueberlassung" enthaelt, kostet damit keinen Cent.
|
||||
|
||||
## Wann laeuft er
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
T["Cron-Tick, jede Minute"] --> A{"scanning_active?"}
|
||||
A -->|nein| STOP1["nichts tun"]
|
||||
A -->|ja| B{"Intervall geaendert?"}
|
||||
B -->|ja| RE["neu planen"]
|
||||
B -->|nein| C{"Sperrfrist nach Start<br/>SCANNER_SCHEDULER_OFFSET"}
|
||||
C -->|noch aktiv| STOP2["nichts tun"]
|
||||
C -->|abgelaufen| D{"Mo-Fr?"}
|
||||
D -->|nein| STOP3["nichts tun"]
|
||||
D -->|ja| E{"07:00-21:59?"}
|
||||
E -->|nein| STOP4["nichts tun"]
|
||||
E -->|ja| F{"Intervall seit<br/>letztem Lauf um?"}
|
||||
F -->|nein| STOP5["nichts tun"]
|
||||
F -->|ja| G["Pipeline starten"]
|
||||
```
|
||||
|
||||
Die **Sperrfrist** ist die Antwort auf ein konkretes Problem: beide Instanzen
|
||||
benutzen dieselben Portal-Cookies. Ohne Versatz ziehen nach einem Neustart beide
|
||||
gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium
|
||||
gleichzeitig belasten den Speicher doppelt.
|
||||
|
||||
> Fruehere Fassung war fehlerhaft: der Versatz wurde ueber einen vorgealterten
|
||||
> `lastRunTime` gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**,
|
||||
> nie verzoegern - bei Intervall 15 und Versatz 30 lief er deshalb sofort. Jetzt
|
||||
> ist es eine eigene Frist (`notBefore`).
|
||||
|
||||
## Parallelbetrieb - alles verdoppelt
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph alt["alte App (scanner.perinco.de)"]
|
||||
A1["Express :5005"] --> A2[("project_scanner")]
|
||||
A1 --> A3["uploads/ in<br/>/var/www/Projekt Scanner/backend"]
|
||||
A4["ENCRYPTION_KEY"] --- A1
|
||||
end
|
||||
|
||||
subgraph neu["CRM (pcrm.perinco.de)"]
|
||||
N1["Express :5006<br/>/api/scanner"] --> N2[("perinco_scanner")]
|
||||
N1 --> N3["uploads/ in<br/>/var/www/perinco_CRM/server"]
|
||||
N4["SCANNER_ENCRYPTION_KEY"] --- N1
|
||||
end
|
||||
|
||||
B["Projektboersen"]
|
||||
O["OpenAI"]
|
||||
A1 --> B
|
||||
N1 --> B
|
||||
A1 --> O
|
||||
N1 --> O
|
||||
|
||||
IMP["npm run scanner:import"] -.->|"liest"| A2
|
||||
IMP -.->|"schreibt"| N2
|
||||
IMP -.->|"kopiert"| N3
|
||||
```
|
||||
|
||||
| | alte App | CRM |
|
||||
| --- | --- | --- |
|
||||
| Datenbank | `project_scanner` | `perinco_scanner` |
|
||||
| Schluessel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` |
|
||||
| CV-Dateien | eigener Ordner | eigener Ordner |
|
||||
| Aufraeumen | 03:00 | 03:30 |
|
||||
| Anmeldung | HTTP Basic | CRM-Sitzung (JWT) |
|
||||
|
||||
**Der Preis:** Beide bewerten dieselben Projekte unabhaengig voneinander, die
|
||||
OpenAI-Kosten fallen also doppelt an. Wer das senken will, stellt im CRM ein
|
||||
groesseres Suchintervall ein - die Einstellung gilt nur fuer diese Instanz.
|
||||
|
||||
## Uebernahme aus der alten Datenbank
|
||||
|
||||
`server/src/scanner/importLegacy.js`, aufrufbar als `npm run scanner:import`:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S["Start"] --> C{"Zieltabelle agents leer?"}
|
||||
C -->|nein, ohne --force| AB["Abbruch"]
|
||||
C -->|ja oder --force| E["Einstellungen kopieren<br/>inkl. OpenAI-Key"]
|
||||
E --> P["Portale ueber den Namen zuordnen:<br/>Benutzername, Session-Cookies, Status"]
|
||||
P --> PW{"LEGACY_ENCRYPTION_KEY gesetzt?"}
|
||||
PW -->|ja| PW2["Passwort entschluesseln<br/>und neu verschluesseln"]
|
||||
PW -->|nein| PW3["Passwort auslassen"]
|
||||
PW2 --> A["Agenten anlegen, Ids behalten<br/>CV-PDF kopieren"]
|
||||
PW3 --> A
|
||||
A --> M{"--with-matches?"}
|
||||
M -->|ja| M2["Projekte und Treffer<br/>mit Original-Ids"]
|
||||
M -->|nein| F["fertig"]
|
||||
M2 --> F
|
||||
```
|
||||
|
||||
Die Ids bleiben erhalten, damit uebernommene Treffer weiter auf die richtigen
|
||||
Agenten und Projekte zeigen. Deshalb bricht das Skript bei bereits vorhandenen
|
||||
Agenten ab.
|
||||
|
||||
> **Beim Rollout am 06.08.2026 festgestellt:** Die Portal-Passwoerter der alten
|
||||
> Datenbank 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. Die
|
||||
> Session-Cookies liegen im Klartext und wurden uebernommen; damit scrapt die
|
||||
> neue Instanz sofort.
|
||||
|
||||
## Oberflaeche
|
||||
|
||||
| Reiter | Datei | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | `ScannerDashboard.tsx` | Kennzahlen, Filter, Trefferliste, *Jetzt scannen*, Ton bei neuen Treffern |
|
||||
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
|
||||
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
|
||||
| Boersen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
|
||||
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, OpenAI, Ton, E-Mail |
|
||||
|
||||
**Wichtig zum Ton:** Er haengt an der Dashboard-Komponente. Wer auf einen anderen
|
||||
Menuepunkt wechselt, hoert nichts - die Komponente wird ausgebaut und die
|
||||
30-Sekunden-Abfrage endet. In einem anderen Browsertab bleibt der Ton dagegen
|
||||
aktiv, nur langsamer (Chrome bremst Timer versteckter Tabs auf etwa einmal pro
|
||||
Minute) und nur, wenn zuvor irgendwo auf der Seite geklickt wurde
|
||||
(Autoplay-Regel). Browserunabhaengig meldet nur die E-Mail.
|
||||
|
||||
## Endpunkte
|
||||
|
||||
Alle unter `/api/scanner`, alle hinter `requireAuth`:
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
| --- | --- | --- |
|
||||
| GET | `/agents` | Liste ohne `cvText` (Nutzlast) |
|
||||
| POST/PUT | `/agents[/:id]` | anlegen/aendern, `multipart` mit CV |
|
||||
| DELETE | `/agents/:id` | Agent samt CV-Datei |
|
||||
| GET | `/agents/:id/cv` | PDF als Stream |
|
||||
| GET | `/portals` | Status ohne Geheimnisse |
|
||||
| POST | `/portals/credentials` | Zugangsdaten, AES-verschluesselt |
|
||||
| POST | `/portals/sync-session` | Cookie-Import, normalisiert fuer Playwright |
|
||||
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Pruefung |
|
||||
| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` |
|
||||
| PUT/DELETE | `/matches/:id` | Status setzen, loeschen |
|
||||
| GET/POST | `/settings` | Geheimnisse maskiert |
|
||||
| POST | `/scan` | Lauf starten, `409` wenn schon einer laeuft |
|
||||
| GET | `/status` | laeuft ein Lauf, Scheduler an, Datenbankname |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 04 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**.
|
||||
|
||||
Das Backend ist daran nicht beteiligt und sieht keine Audiodaten: kein API-Key,
|
||||
keine Kosten pro Minute, kein Datenabfluss. Aufnahmen werden nirgends gespeichert.
|
||||
|
||||
## Weg der Aufnahme
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
MIC(("Mikrofon")) --> MR["MediaRecorder"]
|
||||
MR --> AC["AudioContext<br/>16 kHz Mono PCM"]
|
||||
AC --> W["Web Worker"]
|
||||
W --> ONNX["Whisper als ONNX<br/>WebGPU, sonst WASM"]
|
||||
ONNX --> T["Text"]
|
||||
T --> F["Formularfeld"]
|
||||
|
||||
HF["Hugging-Face-CDN"] -.->|"einmalig 759 MB"| CACHE[("Cache-Speicher<br/>der Seite")]
|
||||
CACHE --> ONNX
|
||||
```
|
||||
|
||||
Die Erkennung liegt im Web Worker (`src/lib/whisper.worker.ts`), damit die
|
||||
Oberflaeche waehrenddessen nicht einfriert. Die ONNX-Laufzeit wird mit
|
||||
ausgeliefert (Vite bundelt die `.wasm`), nur die Modelldateien kommen beim ersten
|
||||
Laden vom CDN und bleiben danach im Cache-Speicher der Seite.
|
||||
|
||||
## Lebenszyklus des Modells
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle : Anmeldung
|
||||
idle --> loading : "Sprachmodell laden" oder<br/>automatisch, wenn schon im Cache
|
||||
loading --> ready : im Worker initialisiert
|
||||
loading --> error : Abbruch, Haenger, kein Speicher
|
||||
error --> loading : erneut versuchen
|
||||
ready --> idle : "Sprachmodell loeschen"
|
||||
ready --> ready : Diktat benutzen
|
||||
|
||||
note right of loading
|
||||
Phase download: Dateien holen
|
||||
Phase init: Modell im Worker aufbauen
|
||||
end note
|
||||
```
|
||||
|
||||
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
|
||||
diesem Browser liegt. Ein Download von 759 MB startet nie von allein, und der
|
||||
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht,
|
||||
bekommt es nicht sofort wieder heruntergeladen.
|
||||
|
||||
## Wo es sitzt
|
||||
|
||||
Reiter **Whisper** in den Einstellungen - fuer **jede Rolle** sichtbar, denn jeder
|
||||
richtet sein Diktat auf seinem eigenen Geraet ein. Genau deshalb sind die
|
||||
Einstellungen ueberhaupt fuer alle Rollen zugaenglich; welche Reiter erscheinen,
|
||||
entscheidet die Rolle:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E["Einstellungen"] --> A["Mein Konto<br/>(alle)"]
|
||||
E --> W["Whisper<br/>(alle)"]
|
||||
E --> N["Nutzerverwaltung<br/>(admin)"]
|
||||
E --> S["Scanner<br/>(admin)"]
|
||||
```
|
||||
|
||||
Ist das Modell noch nicht geladen, wird der Mikrofon-Button zu *Diktat
|
||||
einrichten* und springt per `goTo('Einstellungen', { tab: 'whisper' })` direkt
|
||||
dorthin - ein offener Dialog wird vorher geschlossen (`onBeforeNavigate`).
|
||||
|
||||
## Warum keine Auswahl
|
||||
|
||||
Modell, Genauigkeit, Rechenweg und Sprache stehen als Konstanten in
|
||||
`src/lib/whisper.tsx`: `large-v3-turbo`, `q4`, Grafikkarte wenn vorhanden,
|
||||
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
|
||||
kaputt - bei `small` in fp16 auf WebGPU reagiert der ganze Browser-Tab nicht mehr,
|
||||
und **kein Timeout in der Seite kann das abfangen**, weil das Skript selbst nicht
|
||||
mehr laeuft.
|
||||
|
||||
Eine Auswahl haette also nur Gelegenheit geboten, etwas falsch zu machen. Die
|
||||
Messwerte stehen im [README](../README.md#warum-small-und-q4---die-messungen).
|
||||
|
||||
Als Netz bleibt eine Frist pro Erkennung (15x Aufnahmedauer, mindestens 45 s,
|
||||
hoechstens 5 min) und eine Haenger-Erkennung beim Laden.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 05 Frontend
|
||||
|
||||
Eine einzige React-Anwendung ohne Router: `App.tsx` haelt die aktuelle Ansicht in
|
||||
einem Zustand und rendert sie. Das genuegt, weil die App hinter einer Anmeldung
|
||||
sitzt und keine tiefen Links braucht.
|
||||
|
||||
## Menue und Ansichten
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SB["Seitenleiste"] --> K["Kategorien"]
|
||||
K --> O["Opportunities<br/>(Startansicht)"]
|
||||
K --> C["Kalender"]
|
||||
K --> L["Kontakte"]
|
||||
K --> SC["Scanner"]
|
||||
SC --> SC1["Dashboard"]
|
||||
SC --> SC2["Unpassend"]
|
||||
SC --> SC3["Suchagenten"]
|
||||
SC --> SC4["Boersen-Konten"]
|
||||
SB --> E["Einstellungen"]
|
||||
E --> E1["Nutzerverwaltung (admin)"]
|
||||
E --> E2["Mein Konto"]
|
||||
E --> E3["Scanner (admin)"]
|
||||
E --> E4["Whisper"]
|
||||
```
|
||||
|
||||
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
|
||||
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
|
||||
die Unterpunkte kommen mit den Beschriftungen zurueck.
|
||||
|
||||
## Zustand und Datenfluss
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Kontexte["Kontexte in main.tsx"]
|
||||
TH["ThemeProvider<br/>hell/dunkel/System"]
|
||||
TO["ToastProvider"]
|
||||
AU["AuthProvider<br/>JWT, Benutzer"]
|
||||
WH["WhisperProvider<br/>Modell, Worker"]
|
||||
end
|
||||
|
||||
APP["App.tsx<br/>currentView, settingsTab"]
|
||||
NAV["NavigationProvider<br/>goTo(view, {tab})"]
|
||||
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
|
||||
|
||||
Kontexte --> APP
|
||||
APP --> NAV
|
||||
APP --> DATA
|
||||
DATA --> V1["Opportunities"]
|
||||
DATA --> V2["Kalender"]
|
||||
DATA --> V3["Kontakte"]
|
||||
NAV --> DF["DictationField<br/>springt nach Whisper"]
|
||||
|
||||
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
|
||||
```
|
||||
|
||||
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
|
||||
Scanner-Ansichten laden dagegen selbst, weil sie eine andere Datenbank und eine
|
||||
eigene Auffrischung im 30-Sekunden-Takt haben.
|
||||
|
||||
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein
|
||||
Speichern im Dialog nicht die ganze Liste flackern laesst.
|
||||
|
||||
## Komponentenkarte
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx Layout, Datenladen, Ansichtswechsel
|
||||
types.ts Gemeinsame Typen, Farbpalette, Rechenhilfen
|
||||
lib/
|
||||
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
|
||||
scannerApi.ts Typisierte Endpunkte unter /api/scanner
|
||||
auth.tsx Anmeldung, Benutzer, Rolle
|
||||
theme.tsx Hell/Dunkel/System
|
||||
navigation.tsx Ansichtswechsel fuer tiefe Komponenten
|
||||
whisper.tsx Modell, Worker-Lebenszyklus
|
||||
dictation.tsx Aufnahme-Hook
|
||||
audio.ts Aufnahme zu 16-kHz-Mono-PCM
|
||||
imageCompress.ts Bilder vor dem Upload verkleinern
|
||||
components/
|
||||
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
|
||||
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
|
||||
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
||||
calendar/ Monat/Woche/Tag, Termine, @-Mentions
|
||||
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
|
||||
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
|
||||
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
|
||||
whisper/ Einrichtung der Spracherkennung
|
||||
ui/ Modal, Avatare, Diktatfeld
|
||||
```
|
||||
|
||||
## Dark Mode ohne `dark:`-Varianten
|
||||
|
||||
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 -
|
||||
kein `dark:` im Markup.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["Umschalter im Header"] --> P["localStorage<br/>perinco_crm_theme"]
|
||||
P --> H["class 'dark' auf html"]
|
||||
H --> V["Variablen in index.css<br/>canvas, surface, ink-*, brand-*"]
|
||||
V --> U["Utility-Klassen der Komponenten"]
|
||||
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
|
||||
```
|
||||
|
||||
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
|
||||
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
|
||||
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
|
||||
farbigen Flaechen) und `brand-500` (das CI-Gruen der Buttons).
|
||||
@@ -0,0 +1,134 @@
|
||||
# 06 Betrieb
|
||||
|
||||
## Deployment
|
||||
|
||||
Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen. Der
|
||||
Server hat wenig freien RAM und volles Swap - dort wird nicht gebaut.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L["Arbeitsplatz"] -->|"npm run build"| D["dist/"]
|
||||
D -->|pscp| S1["/var/www/perinco_CRM/dist"]
|
||||
L -->|pscp| S2["/var/www/perinco_CRM/server/src"]
|
||||
L -->|pscp| S3["server/package.json"]
|
||||
S3 -.->|"nur mit -Deps"| NI["npm install --omit=dev"]
|
||||
NI --> R["systemctl restart<br/>perinco-crm-backend"]
|
||||
S2 --> R
|
||||
R --> H["curl /api/health"]
|
||||
```
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
powershell -File deploy/deploy.ps1 # dist + Backend
|
||||
powershell -File deploy/deploy.ps1 -Deps # zusaetzlich npm install
|
||||
powershell -File deploy/deploy.ps1 -SkipBuild # ohne neuen Build
|
||||
```
|
||||
|
||||
Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`.
|
||||
|
||||
`deploy.ps1` laedt die systemd-Unit **nicht** mit hoch. Nach Aenderungen an
|
||||
`deploy/perinco-crm-backend.service` von Hand kopieren und `daemon-reload`.
|
||||
|
||||
## Dienste auf dem Server
|
||||
|
||||
| Unit | Verzeichnis | Port | Anmerkung |
|
||||
| --- | --- | --- | --- |
|
||||
| `perinco-crm-backend` | `/var/www/perinco_CRM/server` | 5006 | `MemoryMax=1536M` |
|
||||
| `project-scanner-backend` | `/var/www/Projekt Scanner/backend` | 5005 | alte App, laeuft bis zur Ablosung |
|
||||
|
||||
Der Verzeichnisname der alten App enthaelt ein **Leerzeichen**. In Skripten und in
|
||||
der `.env` immer in Anfuehrungszeichen setzen - systemd liest `.env` als
|
||||
`EnvironmentFile` und trennt sonst am Leerzeichen.
|
||||
|
||||
`MemoryMax=1536M` statt der urspruenglichen 512M: ein Scan-Lauf startet Chromium
|
||||
als Kindprozess in derselben cgroup. Mit 512M wuerde der OOM-Killer das Backend
|
||||
mitten im Scrapen beenden.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
`server/.env`, Rechte `600`. Vorlage: `server/.env.example`.
|
||||
|
||||
| Variable | Bedeutung |
|
||||
| --- | --- |
|
||||
| `PORT`, `HOST` | `5006`, `127.0.0.1` - von aussen nur ueber Apache |
|
||||
| `DB_*` | Verbindung zu `perinco_CRM` |
|
||||
| `JWT_SECRET`, `JWT_EXPIRES_IN` | Signatur der Sitzungen, Standard 12 h |
|
||||
| `ADMIN_*` | wird beim ersten Start angelegt, nur wenn `crm_users` leer ist |
|
||||
| `SCANNER_DB_NAME` | eigene Scanner-Datenbank, Standard `perinco_scanner` |
|
||||
| `SCANNER_ENCRYPTION_KEY` | AES-Schluessel fuer Portal-Passwoerter |
|
||||
| `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `<server>/uploads` |
|
||||
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
|
||||
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 |
|
||||
| `LEGACY_*` | Quelle fuer `npm run scanner:import` |
|
||||
|
||||
Die Scanner-Datenbank wird beim Start automatisch angelegt
|
||||
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
|
||||
und Standardeinstellungen werden geseedet.
|
||||
|
||||
## Playwright
|
||||
|
||||
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App.
|
||||
Deren Chromium-Build (`chromium-1223`) liegt in `/root/.cache/ms-playwright`, also
|
||||
faellt kein zweiter Browser-Download an. Ein `^` in der Version wuerde das
|
||||
aufgeben; die Begruendung steht als `comments`-Feld in `server/package.json`.
|
||||
|
||||
Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest:
|
||||
|
||||
```bash
|
||||
cd /var/www/perinco_CRM/server && npx playwright install chromium
|
||||
```
|
||||
|
||||
## Erststart des Scanners
|
||||
|
||||
```bash
|
||||
bash /var/www/perinco_CRM/deploy/scanner-setup.sh # .env ergaenzen, Schluessel erzeugen
|
||||
cd /var/www/perinco_CRM/server
|
||||
npm run scanner:import -- --dry-run
|
||||
npm run scanner:import -- --with-matches
|
||||
systemctl restart perinco-crm-backend
|
||||
```
|
||||
|
||||
> **Reihenfolge:** Der Import schreibt `search_interval` aus der alten App in die
|
||||
> Einstellungen. Der laufende Scheduler merkt die Aenderung binnen einer Minute
|
||||
> und plant sich neu - beim Rollout hat er direkt nach dem Import einen Scan
|
||||
> gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber
|
||||
> vermeidbar: erst neu starten, dann importieren.
|
||||
|
||||
## Stoerungssuche
|
||||
|
||||
| Symptom | Blick darauf |
|
||||
| --- | --- |
|
||||
| Anmeldung schlaegt 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 |
|
||||
| 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` |
|
||||
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
|
||||
|
||||
Nuetzliche Abfragen:
|
||||
|
||||
```bash
|
||||
systemctl status perinco-crm-backend
|
||||
journalctl -u perinco-crm-backend -f
|
||||
curl -s http://127.0.0.1:5006/api/health
|
||||
|
||||
set -a; . /var/www/perinco_CRM/server/.env; set +a
|
||||
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" -e \
|
||||
"SELECT COUNT(*) FROM matches; SELECT key_name, value FROM settings"
|
||||
```
|
||||
|
||||
## Sicherung
|
||||
|
||||
Beide Datenbanken einzeln, dazu die CV-Dateien:
|
||||
|
||||
```bash
|
||||
set -a; . /var/www/perinco_CRM/server/.env; set +a
|
||||
mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" > crm_$(date +%F).sql
|
||||
mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" > scanner_$(date +%F).sql
|
||||
tar czf uploads_$(date +%F).tar.gz -C /var/www/perinco_CRM/server uploads
|
||||
```
|
||||
|
||||
Die `.env` gehoert **nicht** ins Repository, aber in die Sicherung - ohne
|
||||
`SCANNER_ENCRYPTION_KEY` sind die Portal-Passwoerter verloren. Genau das ist der
|
||||
alten App passiert.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Dokumentation perinco CRM
|
||||
|
||||
Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und
|
||||
werden von Gitea direkt gerendert.
|
||||
|
||||
| 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 Boerse bis zum Treffer, Parallelbetrieb |
|
||||
| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus |
|
||||
| [05 Frontend](05-frontend.md) | Ansichten, Menue, Komponentenkarte, Zustand |
|
||||
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Stoerungssuche |
|
||||
|
||||
Die Anwendungsdokumentation - Bedienung, Designentscheidungen, Messwerte zur
|
||||
Spracherkennung - steht 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"]
|
||||
PW["Playwright<br/>Chromium"]
|
||||
DB1[("MariaDB<br/>perinco_CRM")]
|
||||
DB2[("MariaDB<br/>perinco_scanner")]
|
||||
ALT["alte Scanner-App<br/>:5005"]
|
||||
DB3[("MariaDB<br/>project_scanner")]
|
||||
end
|
||||
|
||||
OAI["OpenAI<br/>gpt-4o-mini"]
|
||||
BOERSE["Projektboersen<br/>freelancermap, hays, ..."]
|
||||
|
||||
SPA -->|"HTTPS /api"| AP
|
||||
AP --> API
|
||||
API --> DB1
|
||||
API --> DB2
|
||||
SCH --> PW
|
||||
PW --> BOERSE
|
||||
SCH --> OAI
|
||||
SCH --> DB2
|
||||
API -.-> SCH
|
||||
ALT --> DB3
|
||||
ALT --> BOERSE
|
||||
SPA -.->|"Audio bleibt im Browser"| WSP
|
||||
|
||||
classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3
|
||||
class ALT,DB3 alt
|
||||
```
|
||||
|
||||
Gestrichelt: die urspruengliche Scanner-App, die bis zur Ablosung parallel
|
||||
weiterlaeuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst.
|
||||
Reference in New Issue
Block a user