feat(scanner): Agenten nachtraeglich uebernehmen mit --agents-only und --skip

Der Legacy-Import war ein Erststart-Werkzeug: er brach bei vorhandenen
Agenten ab und schrieb immer auch Einstellungen und Portale mit. Um
spaeter einzelne Suchagenten aus project_scanner nachzuziehen, gab es
keinen Weg.

--agents-only laesst Einstellungen, Portale, Projekte und Treffer
unberuehrt und laeuft additiv: vorhandene Agenten blockieren nicht mehr,
sondern werden am Namen erkannt und uebersprungen, damit im CRM
bearbeitete Agenten nicht ueberschrieben werden. Die Id der Quelle wird
nur uebernommen, solange sie im Ziel frei ist.

--skip=Name[,Name] laesst Agenten nach Namen aus, ohne Ruecksicht auf
Gross- und Kleinschreibung.

--force ist mit --agents-only gesperrt: es wuerde Projekte und Treffer
loeschen, die der Lauf gar nicht anfasst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 08:57:21 +02:00
co-authored by Claude Opus 5
parent 29316633a6
commit a866cd9a52
3 changed files with 88 additions and 11 deletions
+5
View File
@@ -321,8 +321,13 @@ npm run scanner:import -- --dry-run # nur anzeigen
npm run scanner:import # Einstellungen, Portale, Agenten + CVs
npm run scanner:import -- --with-matches # zusätzlich Projekte und Treffer
npm run scanner:import -- --force # vorhandene Zieldaten vorher löschen
npm run scanner:import -- --agents-only --skip=Bilal # später: nur fehlende Agenten
```
`--agents-only` ist der nachträgliche Lauf: nur Suchagenten, additiv, vorhandene
werden über den Namen erkannt und übersprungen. `--skip=` lässt Agenten nach
Namen aus. Details: [03 Projekt Scanner](docs/03-scanner.md#agenten-nachträglich-holen).
Quelle kommt aus `LEGACY_*` in `server/.env`:
| Variable | Wofür |
+20
View File
@@ -220,6 +220,26 @@ Die Ids bleiben erhalten, damit übernommene Treffer weiter auf die richtigen
Agenten und Projekte zeigen. Deshalb bricht das Skript bei bereits vorhandenen
Agenten ab.
### Agenten nachträglich holen
Der Vollimport ist ein Erststart-Werkzeug. Um später nur die Suchagenten
nachzuziehen, ohne Einstellungen, Portale und Treffer anzufassen:
```bash
cd /var/www/perinco_CRM/server
npm run scanner:import -- --agents-only --skip=Bilal --dry-run
npm run scanner:import -- --agents-only --skip=Bilal
```
`--agents-only` läuft **additiv**: vorhandene Agenten blockieren nicht, sondern
werden über den Namen erkannt und übersprungen - was hier im CRM schon
bearbeitet wurde, bleibt also unangetastet. Die Id der Quelle wird nur
übernommen, solange sie in `perinco_scanner` frei ist; sonst vergibt die
Datenbank eine neue. `--skip=` nimmt einen Namen oder eine Liste
(`--skip=Bilal,Nico`), Groß- und Kleinschreibung spielt keine Rolle.
`--force` ist in diesem Modus gesperrt: es würde Projekte und Treffer löschen,
die der Lauf gar nicht anrührt.
> **Beim Rollout am 06.08.2026 festgestellt:** Die Portal-Passwörter der alten
> Datenbank lassen sich mit dem dortigen `ENCRYPTION_KEY` nicht mehr
> entschlüsseln - der Schlüssel wurde nach dem Speichern getauscht. Die alte App
+63 -11
View File
@@ -8,6 +8,8 @@
* npm run scanner:import -- --with-matches auch Projekte und Treffer
* npm run scanner:import -- --force vorhandene Zieldaten ersetzen
* npm run scanner:import -- --dry-run nur anzeigen, nichts schreiben
* npm run scanner:import -- --agents-only nur Suchagenten, sonst nichts
* npm run scanner:import -- --skip=Bilal,Nico Agenten nach Namen auslassen
*
* Quelle konfigurieren (server/.env):
* LEGACY_DB_NAME=project_scanner
@@ -22,6 +24,11 @@
* sind und hier neu verschluesselt werden muessen
* - Suchagenten inkl. CV-Text und CV-PDF (Datei wird kopiert)
* - mit --with-matches zusaetzlich Projekte und Treffer
*
* --agents-only laesst Einstellungen und Portale unberuehrt und laeuft
* nachtraeglich: vorhandene Agenten blockieren nicht mehr, sondern werden am
* Namen erkannt und uebersprungen. Die Ids der Quelle werden nur uebernommen,
* solange sie hier frei sind - sonst vergibt die Datenbank eine neue.
*/
import 'dotenv/config';
import fs from 'node:fs';
@@ -42,6 +49,14 @@ const args = process.argv.slice(2);
const withMatches = args.includes('--with-matches');
const force = args.includes('--force');
const dryRun = args.includes('--dry-run');
const agentsOnly = args.includes('--agents-only');
// --skip=Bilal oder --skip=Bilal,Nico - Vergleich ohne Gross-/Kleinschreibung.
const skipNames = args
.filter((arg) => arg.startsWith('--skip='))
.flatMap((arg) => arg.slice('--skip='.length).split(','))
.map((name) => name.trim().toLowerCase())
.filter(Boolean);
const LEGACY_UPLOAD_DIR = process.env.LEGACY_UPLOAD_DIR || '';
const LEGACY_KEY = process.env.LEGACY_ENCRYPTION_KEY || '';
@@ -144,17 +159,39 @@ async function importAgents(legacy, uploadDir) {
FROM agents ORDER BY id`
);
let created = 0;
let skipped = 0;
for (const row of rows) {
const cvFilePath = copyCv(row.cv_file_path, uploadDir);
if (dryRun) {
log(` wuerde Agent ${row.id} "${row.name}" anlegen`);
if (skipNames.includes((row.name || '').trim().toLowerCase())) {
log(` Agent "${row.name}" per --skip ausgelassen.`);
skipped++;
continue;
}
// Ids bleiben erhalten, damit uebernommene Treffer weiter passen.
// Nachtraegliche Uebernahme: was hier schon unter dem Namen steht, bleibt
// unangetastet - der Agent koennte hier inzwischen bearbeitet worden sein.
const existing = await Agent.findOne({ where: { name: row.name } });
if (existing) {
log(` Agent "${row.name}" gibt es hier schon (Id ${existing.id}) - uebersprungen.`);
skipped++;
continue;
}
if (dryRun) {
log(` wuerde Agent ${row.id} "${row.name}" anlegen`);
created++;
continue;
}
const cvFilePath = copyCv(row.cv_file_path, uploadDir);
// Die Id der Quelle bleibt erhalten, damit uebernommene Treffer weiter
// passen - aber nur, wenn sie hier noch frei ist.
const idTaken = await Agent.findByPk(row.id);
await Agent.create(
{
id: row.id,
...(idTaken ? {} : { id: row.id }),
name: row.name,
email: row.email,
cvText: row.cv_text,
@@ -167,9 +204,10 @@ async function importAgents(legacy, uploadDir) {
},
{ silent: true }
);
created++;
}
log(`Suchagenten: ${rows.length} uebernommen.`);
log(`Suchagenten: ${created} uebernommen, ${skipped} uebersprungen (${rows.length} in der Quelle).`);
}
async function importProjectsAndMatches(legacy) {
@@ -246,17 +284,25 @@ async function main() {
const uploadDir = ensureUploadDir();
log(`Ziel-Upload-Verzeichnis: ${uploadDir}`);
// Bei --agents-only ist der Lauf nachtraeglich gedacht und laeuft additiv:
// vorhandene Agenten sind kein Grund zum Abbruch, sie werden in
// importAgents am Namen erkannt.
const existingAgents = await Agent.count();
if (existingAgents > 0 && !force) {
if (existingAgents > 0 && !force && !agentsOnly) {
log(
`Abbruch: es gibt hier schon ${existingAgents} Suchagenten. Mit --force werden Agenten, ` +
'Projekte und Treffer vorher geloescht.'
'Projekte und Treffer vorher geloescht, mit --agents-only kommen nur die fehlenden dazu.'
);
process.exit(1);
}
const legacy = await openLegacy();
try {
if (force && agentsOnly) {
log('Abbruch: --force und --agents-only zusammen ergeben keinen Sinn.');
process.exit(1);
}
if (force && !dryRun) {
// Reihenfolge wegen der Fremdschluessel: Treffer, dann Projekte, dann Agenten.
await Match.destroy({ where: {}, truncate: false });
@@ -265,10 +311,16 @@ async function main() {
log('Vorhandene Agenten, Projekte und Treffer geloescht.');
}
await importSettings(legacy);
await importPortals(legacy);
if (agentsOnly) {
log('Nur Suchagenten - Einstellungen und Portale bleiben unberuehrt.');
} else {
await importSettings(legacy);
await importPortals(legacy);
}
await importAgents(legacy, uploadDir);
if (withMatches) {
if (withMatches && !agentsOnly) {
await importProjectsAndMatches(legacy);
} else {
log('Projekte und Treffer uebersprungen (--with-matches aktiviert sie).');