From af3ca33743871c492075fba7d807564ef6f45bc2 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 17 Sep 2026 17:11:57 +0200 Subject: [PATCH] feat(scanner): Funde mit Akquise und Opportunities abgleichen Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14) verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner, Neupruefung von Hand im Detail. Co-Authored-By: Claude Opus 5 (1M context) --- docs/02-datenmodell.md | 3 + docs/03-scanner.md | 72 +++++++- docs/07-anleitung-benutzer.md | 16 ++ docs/09-quellcode.md | 1 + server/src/routes/scanner.js | 20 +++ server/src/scanner/models/Project.js | 30 ++++ server/src/scanner/models/index.js | 15 +- server/src/scanner/services/appliedCheck.js | 188 ++++++++++++++++++++ server/src/scanner/services/pipeline.js | 26 +++ src/components/scanner/MatchDetailModal.tsx | 71 +++++++- src/components/scanner/ScannerDashboard.tsx | 11 +- src/components/scanner/ScannerRejected.tsx | 11 +- src/components/scanner/scannerUi.tsx | 10 ++ src/components/settings/ScannerSettings.tsx | 63 ++++++- src/lib/scannerApi.ts | 22 +++ 15 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 server/src/scanner/services/appliedCheck.js diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index d9f8577..41b2bfd 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -332,6 +332,9 @@ erDiagram varchar rate varchar url datetime published_at + tinyint already_applied "in Akquise/Opportunities gefunden" + text applied_refs "JSON: type, id, title, consultant, reason" + datetime applied_check_at "NULL = noch nicht abgeglichen" timestamp created_at "gescrapt am, Aufbewahrung 7 Tage" } matches { diff --git a/docs/03-scanner.md b/docs/03-scanner.md index 8251c0b..e3f034f 100644 --- a/docs/03-scanner.md +++ b/docs/03-scanner.md @@ -40,12 +40,81 @@ sequenceDiagram P->>M: E-Mail an Agent oder Standardempfaenger end end + + opt Abgleich aktiv, Projekt hat Treffer, noch nicht geprüft + P->>P: Akquise und Opportunities der letzten N Tage laden + P->>AI: gpt-5.6-luna: Projekt vs. CRM-Einträge + AI-->>P: { matches: [{ type, id, reason }] } + P->>DB: already_applied, applied_refs, applied_check_at + end ``` Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein Projekt, das „Arbeitnehmerüberlassung" enthält, kostet damit keinen Cent. +## Abgleich „bereits beworben!" + +Dieselbe Ausschreibung kommt oft mehrfach: über zwei Börsen, über mehrere +Vermittler, mit anderem Titel und anderem Link. Damit niemand ein Projekt ein +zweites Mal anbietet, gleicht `services/appliedCheck.js` jeden Fund mit der +**Akquise-Liste** (`crm_acquisitions`) und den **Opportunities** +(`crm_opportunities`) ab. Steht er dort schon, trägt der Treffer im Dashboard, +unter *Unpassend* und im Detail die Markierung **bereits beworben!**; das Detail +nennt die passenden Einträge samt Begründung. + +```mermaid +flowchart TD + A["Projekt im Lauf verarbeitet"] --> B{"applied_check_active?"} + B -->|nein| X["nichts tun"] + B -->|ja| C{"schon geprüft?
applied_check_at"} + C -->|ja| X + C -->|nein| D{"mindestens ein Treffer?"} + D -->|nein| X + D -->|ja| E["Kandidaten laden:
Akquise found_at, Opportunities created_at
der letzten N Tage, ohne Papierkorb"] + E --> F{"Kandidaten?"} + F -->|nein| N["nicht beworben"] + F -->|ja| G{"gleicher Link?"} + G -->|ja| Y["bereits beworben"] + G -->|nein| H["gpt-5.6-luna, reasoning_effort low"] + H -->|Treffer mit bekannter Id| Y + H -->|keine| N + N --> S["am Projekt speichern"] + Y --> S +``` + +| Einstellung | Vorgabe | Bedeutung | +| --- | --- | --- | +| `applied_check_active` | `true` | Schalter in *Einstellungen → Scanner* | +| `applied_check_days` | `14` | Zeitraum zurück, in Tagen (Oberfläche: Tage oder Wochen), höchstens 365 | + +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 + 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`. +- **Einmal je Projekt, nur mit Treffer.** Die Börsen liefern bei jedem Lauf + dieselben Projekte erneut; `applied_check_at` verhindert, dass sie jedes Mal + bezahlt werden. Projekte ohne Treffer zeigt die Oberfläche nie an. +- **Gleicher Link braucht keine KI.** Verglichen wird ohne Protokoll, `www`, + Query und Schrägstrich am Ende. +- **Nur Ids, die wir geschickt haben.** Nennt das Modell eine unbekannte Id, + zählt sie nicht. +- **Fehler setzen aus, nicht ab.** Scheitert der Aufruf (Schlüssel, Quote, + Netz), bleibt der Abgleich für den Rest des Laufs aus und das Projekt + ungeprüft - der nächste Lauf versucht es wieder. Die Bewertung läuft davon + unberührt weiter. +- **Momentaufnahme.** Geprüft wird beim Fund. Wird die Bewerbung erst danach + in der Akquise erfasst, holt *Auf Bewerbung prüfen* im Detail den Abgleich + von Hand nach (`POST /projects/:id/applied-check`, auch bei abgeschaltetem + Automatismus). + +Die drei Spalten an `projects` legt `initScannerDatabase()` per +`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` an - `sync()` ohne `alter` tut das in +bestehenden Tabellen nicht. + ## Wann läuft er ```mermaid @@ -215,7 +284,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, OpenAI, Ton, E-Mail | +| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, OpenAI, Ton, E-Mail | **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 @@ -240,6 +309,7 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`: | POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Prüfung | | GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` | | PUT/DELETE | `/matches/:id` | Status setzen, löschen | +| POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen | | GET/POST | `/settings` | Geheimnisse maskiert | | POST | `/scan` | Lauf starten, `409` wenn schon einer läuft | | GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname | diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index dbfea12..a57ad55 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -819,6 +819,17 @@ wissen: 3. **Ist der Tab geschlossen, passiert nichts.** Wer unabhängig vom Browser benachrichtigt werden will, schaltet die **E-Mail** in den Einstellungen ein. +### „bereits beworben!" + +Jeder neue Treffer wird mit der **Akquise-Liste** und den **Opportunities** +abgeglichen. Steht das Projekt dort schon - auch unter anderem Titel oder von +einer anderen Börse -, trägt es in der Liste die rote Markierung **bereits +beworben!**. Im Detail steht, welcher Eintrag passt, für welchen Berater und +warum die KI ihn für dasselbe Projekt hält. + +Geprüft wird einmal, beim Fund. Wer die Bewerbung erst danach in der Akquise +einträgt, klickt im Detail auf **Auf Bewerbung prüfen**. + ### Unpassend Alles, was über *Passt nicht* aussortiert wurde. Von hier bringt *Zurück ins @@ -900,6 +911,11 @@ nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit man nicht rätselt, warum keine neuen Treffer kommen. +Unter **Abgleich mit Akquise und Opportunities** lässt sich die Markierung +*bereits beworben!* ein- und ausschalten. **Zeitraum zurück** bestimmt, wie alt +ein Akquise-Eintrag oder eine Opportunity höchstens sein darf, um verglichen zu +werden - in Tagen oder Wochen, Vorgabe zwei Wochen. + 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 diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index dbecfa6..7855c73 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -229,6 +229,7 @@ Die portierte Scanner-API unter `/api/scanner`, vollständig in | `models/Setting.js` | Schlüssel-Wert mit `getVal`/`setVal` | | `services/scraper.js` | Playwright je Portal; unverändert aus der alten App | | `services/matchingEngine.js` | OpenAI `gpt-4o-mini`, `response_format: json_object`, `temperature: 0.1` | +| `services/appliedCheck.js` | Abgleich mit Akquise/Opportunities, OpenAI `gpt-5.6-luna`, Ergebnis an `projects` | | `services/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen | | `services/scheduler.js` | node-cron jede Minute, Zeitfenster, Sperrfrist, `last_scan_at` | | `services/cleanup.js` | Projekte älter als 7 Tage löschen (Treffer kaskadieren) | diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js index c83a98f..59d4263 100644 --- a/server/src/routes/scanner.js +++ b/server/src/routes/scanner.js @@ -8,6 +8,7 @@ import { parsePdfCv } from '../scanner/services/pdfParser.js'; import { encrypt } from '../scanner/utils/crypto.js'; import { ensureUploadDir, resolveCvPath } from '../scanner/uploadDir.js'; import { isPipelineRunning, runMatchingPipeline } from '../scanner/services/pipeline.js'; +import { appliedCheckSettings, checkProjectAlreadyApplied } from '../scanner/services/appliedCheck.js'; import { scrapeFreelancermap, scrapeFreelanceDe, @@ -456,6 +457,25 @@ router.delete('/matches/:id', async (req, res) => { } }); +/* + * Abgleich "bereits beworben?" fuer ein Projekt von Hand wiederholen - etwa + * nachdem die Bewerbung erst nach dem Fund in der Akquise erfasst wurde. Laeuft + * auch bei abgeschaltetem Automatismus, der Zeitraum gilt trotzdem. + */ +router.post('/projects/:id/applied-check', async (req, res) => { + try { + const project = await Project.findByPk(req.params.id); + if (!project) { + return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + } + const { days } = await appliedCheckSettings(); + await checkProjectAlreadyApplied(project, { days }); + res.json(project); + } catch (error) { + res.status(502).json({ error: `Abgleich fehlgeschlagen: ${error.message}` }); + } +}); + // ========================================== // EINSTELLUNGEN // ========================================== diff --git a/server/src/scanner/models/Project.js b/server/src/scanner/models/Project.js index 3c7a1ba..17221d8 100644 --- a/server/src/scanner/models/Project.js +++ b/server/src/scanner/models/Project.js @@ -45,6 +45,36 @@ const Project = sequelize.define('Project', { allowNull: true, field: 'published_at', }, + // Abgleich mit Akquise und Opportunities (services/appliedCheck.js). Die + // Spalten zieht initScannerDatabase() per ALTER TABLE nach. + alreadyApplied: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'already_applied', + }, + appliedRefs: { + type: DataTypes.TEXT, + allowNull: true, + field: 'applied_refs', + comment: 'JSON array: [{ type, id, title, consultant, reason }]', + get() { + const rawValue = this.getDataValue('appliedRefs'); + try { + return rawValue ? JSON.parse(rawValue) : []; + } catch { + return []; + } + }, + set(value) { + this.setDataValue('appliedRefs', value ? JSON.stringify(value) : null); + }, + }, + appliedCheckAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'applied_check_at', + }, }, { tableName: 'projects', indexes: [ diff --git a/server/src/scanner/models/index.js b/server/src/scanner/models/index.js index fe22f20..4eac78b 100644 --- a/server/src/scanner/models/index.js +++ b/server/src/scanner/models/index.js @@ -19,6 +19,17 @@ const initScannerDatabase = async () => { await sequelize.sync(); console.log('[Scanner] Tabellen synchronisiert.'); + // Spalten, die nach dem ersten Anlegen dazukamen. sync() ohne alter legt + // sie in bestehenden Tabellen nicht an; MariaDB kennt IF NOT EXISTS. + const columnMigrations = [ + 'ALTER TABLE projects ADD COLUMN IF NOT EXISTS already_applied TINYINT(1) NOT NULL DEFAULT 0', + 'ALTER TABLE projects ADD COLUMN IF NOT EXISTS applied_refs TEXT NULL', + 'ALTER TABLE projects ADD COLUMN IF NOT EXISTS applied_check_at DATETIME NULL', + ]; + for (const statement of columnMigrations) { + await sequelize.query(statement); + } + // Ensure every supported portal exists. Per-row findOrCreate so newly added // portals appear after upgrade without manual SQL. const defaultPortals = [ @@ -56,7 +67,9 @@ const initScannerDatabase = async () => { { keyName: 'smtp_from', value: 'scanner@perinco.de' }, { keyName: 'notification_email_recipient', value: '' }, { keyName: 'match_threshold', value: '70' }, - { keyName: 'display_threshold', value: '50' } + { keyName: 'display_threshold', value: '50' }, + { keyName: 'applied_check_active', value: 'true' }, + { keyName: 'applied_check_days', value: '14' } ]; for (const s of defaultSettings) { diff --git a/server/src/scanner/services/appliedCheck.js b/server/src/scanner/services/appliedCheck.js new file mode 100644 index 0000000..4e62a4e --- /dev/null +++ b/server/src/scanner/services/appliedCheck.js @@ -0,0 +1,188 @@ +import { OpenAI } from 'openai'; +import { pool } from '../../db.js'; +import Setting from '../models/Setting.js'; + +/* + * Abgleich "bereits beworben?". + * + * Findet der Scanner ein Projekt, prueft dieser Dienst, ob es in der Akquise + * oder bei den Opportunities des CRM schon auftaucht - dieselbe Ausschreibung + * kommt oft ueber mehrere Boersen oder Vermittler, mit anderem Titel und + * anderem Link. Ein reiner Textvergleich findet das nicht, deshalb entscheidet + * ein Sprachmodell. + * + * Geprueft wird einmal je Projekt (`applied_check_at`), und nur fuer Projekte + * mit mindestens einem Treffer - alles andere zeigt die Oberflaeche nie an. + * + * Einstellungen (Scanner-Tabelle `settings`): + * applied_check_active 'true' | 'false' + * applied_check_days Zeitraum in Tagen, Vorgabe 14 + */ + +export const APPLIED_CHECK_MODEL = 'gpt-5.6-luna'; +export const APPLIED_CHECK_DEFAULT_DAYS = 14; +const MAX_DAYS = 365; + +/** Mehr Kandidaten schicken wir nicht - bei 14 Tagen sind es in der Praxis wenige Dutzend. */ +const MAX_CANDIDATES = 300; + +const clip = (text, length) => { + const value = String(text ?? '').replace(/\s+/g, ' ').trim(); + return value.length > length ? `${value.slice(0, length)}…` : value; +}; + +/** Link ohne Protokoll, www, Query und Slash am Ende - reicht fuer "gleiche Ausschreibung". */ +const normalizeUrl = (url) => + String(url ?? '') + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/^www\./, '') + .replace(/[?#].*$/, '') + .replace(/\/+$/, ''); + +export async function appliedCheckSettings() { + const active = (await Setting.getVal('applied_check_active', 'true')) !== 'false'; + const parsed = Number.parseInt(await Setting.getVal('applied_check_days', ''), 10); + const days = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_DAYS) : APPLIED_CHECK_DEFAULT_DAYS; + return { active, days }; +} + +/** Akquise-Eintraege und Opportunities der letzten `days` Tage, ohne Papierkorb. */ +async function loadCandidates(days) { + const [acquisitions] = await pool.query( + `SELECT id, found_at, title, company, provider, sender, consultant, link, description + FROM crm_acquisitions + WHERE deleted_at IS NULL AND found_at >= CURDATE() - INTERVAL ? DAY + ORDER BY found_at DESC + LIMIT ?`, + [days, MAX_CANDIDATES] + ); + const [opportunities] = await pool.query( + `SELECT id, created_at, title, company, consultant, stage, description + FROM crm_opportunities + WHERE deleted_at IS NULL AND created_at >= NOW() - INTERVAL ? DAY + ORDER BY created_at DESC + LIMIT ?`, + [days, MAX_CANDIDATES] + ); + + return [ + ...acquisitions.map((row) => ({ + type: 'acquisition', + id: row.id, + title: row.title, + company: row.company, + provider: row.provider || row.sender, + consultant: row.consultant, + link: row.link, + date: row.found_at, + description: row.description, + })), + ...opportunities.map((row) => ({ + type: 'opportunity', + id: row.id, + title: row.title, + company: row.company, + consultant: row.consultant, + stage: row.stage, + date: row.created_at, + description: row.description, + })), + ]; +} + +const toRef = (candidate, reason) => ({ + type: candidate.type, + id: candidate.id, + title: candidate.title, + consultant: candidate.consultant || null, + reason, +}); + +async function askModel(project, candidates) { + const apiKey = (await Setting.getVal('openai_api_key')) || process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error('OpenAI API Key is not configured.'); + + const openai = new OpenAI({ apiKey }); + + const system = `Du pruefst, ob eine neu gefundene Projektausschreibung bereits im CRM erfasst ist. +Dieselbe Ausschreibung erscheint oft auf mehreren Portalen oder bei mehreren Vermittlern - mit anderem Titel, anderem Link oder gekuerzter Beschreibung. +Ein Kandidat gilt nur dann als dasselbe Projekt, wenn Aufgabe, Rolle und die wesentlichen Rahmendaten (Technologien, Branche/Kunde, Ort, Zeitraum) klar uebereinstimmen. +Aehnliche Rolle bei anderem Kunden oder anderem Inhalt ist KEIN Treffer. Im Zweifel kein Treffer. + +Antworte ausschliesslich als JSON: +{ "matches": [ { "type": "acquisition" | "opportunity", "id": 123, "reason": "kurze Begruendung auf Deutsch" } ] } +Keine Treffer: { "matches": [] }`; + + const user = `### NEU GEFUNDENES PROJEKT +Titel: ${clip(project.title, 300)} +Portal: ${project.portal} +Firma: ${clip(project.company, 200) || '-'} +Ort: ${clip(project.location, 200) || '-'} +Rate: ${clip(project.rate, 100) || '-'} +Beschreibung: ${clip(project.description, 4000) || '-'} + +### KANDIDATEN IM CRM +${JSON.stringify( + candidates.map((c) => ({ + type: c.type, + id: c.id, + title: clip(c.title, 200), + company: c.company || undefined, + provider: c.provider || undefined, + stage: c.stage || undefined, + link: c.link || undefined, + description: clip(c.description, 500) || undefined, + })) +)}`; + + const response = await openai.chat.completions.create({ + model: APPLIED_CHECK_MODEL, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + response_format: { type: 'json_object' }, + reasoning_effort: 'low', + }); + + const parsed = JSON.parse(response.choices[0]?.message?.content || '{}'); + return Array.isArray(parsed.matches) ? parsed.matches : []; +} + +/** + * Prueft ein Projekt und speichert das Ergebnis am Projekt. + * Wirft bei KI- oder Datenbankfehlern; das Projekt bleibt dann ungeprueft und + * kommt beim naechsten Lauf wieder dran. + * + * @param {import('sequelize').Model} project + * @param {{ days: number }} options + */ +export async function checkProjectAlreadyApplied(project, { days }) { + const candidates = await loadCandidates(days); + let refs = []; + + if (candidates.length > 0) { + // Gleicher Link ist eindeutig - dafuer braucht es kein Modell. + const projectUrl = normalizeUrl(project.url); + refs = candidates + .filter((c) => c.link && projectUrl && normalizeUrl(c.link) === projectUrl) + .map((c) => toRef(c, 'Gleicher Link zur Ausschreibung.')); + + if (refs.length === 0) { + const byKey = new Map(candidates.map((c) => [`${c.type}:${c.id}`, c])); + for (const hit of await askModel(project, candidates)) { + const candidate = byKey.get(`${hit?.type}:${Number(hit?.id)}`); + // Nur Ids, die wir auch geschickt haben - ein erfundener Treffer zaehlt nicht. + if (candidate) refs.push(toRef(candidate, clip(hit.reason, 300))); + } + } + } + + project.alreadyApplied = refs.length > 0; + project.appliedRefs = refs; + project.appliedCheckAt = new Date(); + await project.save(); + return refs; +} diff --git a/server/src/scanner/services/pipeline.js b/server/src/scanner/services/pipeline.js index 949fed1..f769091 100644 --- a/server/src/scanner/services/pipeline.js +++ b/server/src/scanner/services/pipeline.js @@ -1,6 +1,7 @@ import { Agent, Portal, Project, Match, Setting } from '../models/index.js'; import { scrapeFreelancermap, scrapeFreelanceDe, scrapeHays, scrapeEtengo, scrapeSThreePortal, SThreePortals } from './scraper.js'; import { evaluateMatch } from './matchingEngine.js'; +import { appliedCheckSettings, checkProjectAlreadyApplied } from './appliedCheck.js'; import nodemailer from 'nodemailer'; // Helper to encrypt/decrypt credentials if needed, or send email notifications @@ -236,6 +237,13 @@ const executePipeline = async () => { const thresholdStr = await Setting.getVal('match_threshold', '70'); const matchThreshold = parseInt(thresholdStr, 10) || 70; + // Abgleich "bereits beworben?" - einmal je Lauf gelesen. Scheitert die KI + // einmal (Schluessel, Quote, Netz), bleibt er fuer den Rest des Laufs aus, + // statt jedes Projekt mit demselben Fehler zu versuchen. + const appliedCheck = await appliedCheckSettings(); + let appliedCheckAvailable = appliedCheck.active; + let appliedCheckedCount = 0; + for (const scrapedProj of scrapedProjects) { try { // Find or create project in DB @@ -312,6 +320,23 @@ const executePipeline = async () => { } } + // Nur Projekte mit Treffer - nur die zeigt die Oberflaeche an. + if (appliedCheckAvailable && !project.appliedCheckAt) { + const hasMatch = await Match.count({ where: { projectId: project.id } }); + if (hasMatch > 0) { + try { + const refs = await checkProjectAlreadyApplied(project, appliedCheck); + appliedCheckedCount++; + if (refs.length > 0) { + console.log(`[Abgleich] Projekt [${project.title}] bereits im CRM: ${refs.map((r) => `${r.type}#${r.id}`).join(', ')}`); + } + } catch (checkErr) { + appliedCheckAvailable = false; + console.error(`[Abgleich] fehlgeschlagen, fuer diesen Lauf ausgesetzt: ${checkErr.message}`); + } + } + } + } catch (dbErr) { console.error('Error inserting project or creating match:', dbErr.message); } @@ -320,5 +345,6 @@ const executePipeline = async () => { console.log(`--- PIPELINE COMPLETED ---`); console.log(`Stored ${newProjectsStoredCount} new projects.`); console.log(`Evaluated ${matchesEvaluatedCount} new matches.`); + console.log(`Checked ${appliedCheckedCount} projects against Akquise/Opportunities.`); return true; }; diff --git a/src/components/scanner/MatchDetailModal.tsx b/src/components/scanner/MatchDetailModal.tsx index 493818d..d1a95bc 100644 --- a/src/components/scanner/MatchDetailModal.tsx +++ b/src/components/scanner/MatchDetailModal.tsx @@ -1,13 +1,21 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Modal } from '../ui/Modal'; -import { MatchBadge, MatchStatusBadge, formatDateTime } from './scannerUi'; -import type { MatchStatus, ScannerMatch } from '../../lib/scannerApi'; +import { useToast } from '../Toast'; +import { AlreadyAppliedBadge, MatchBadge, MatchStatusBadge, formatDateTime } from './scannerUi'; +import { + scannerApi, + type MatchStatus, + type ScannerMatch, + type ScannerProject, +} from '../../lib/scannerApi'; interface MatchDetailModalProps { match: ScannerMatch; onClose: () => void; onStatusChange: (status: MatchStatus) => void; onDelete: () => void; + /** Nach "Neu prüfen" - das Projekt kann an mehreren Treffern haengen. */ + onProjectChange?: (project: ScannerProject) => void; /** * "rejected" ist die Ansicht aus Unpassend: dort gibt es nur * Zurueckholen und Loeschen, keine Statuskette. @@ -27,9 +35,32 @@ export const MatchDetailModal: React.FC = ({ onClose, onStatusChange, onDelete, + onProjectChange, variant = 'default', }) => { - const project = match.Project; + const { addToast } = useToast(); + const [project, setProject] = useState(match.Project); + const [isChecking, setIsChecking] = useState(false); + + const recheck = async () => { + if (!project) return; + setIsChecking(true); + try { + const updated = await scannerApi.recheckApplied(project.id); + setProject(updated); + onProjectChange?.(updated); + addToast( + updated.alreadyApplied + ? 'Projekt ist bereits im CRM erfasst.' + : 'Kein passender Eintrag in Akquise oder Opportunities.', + updated.alreadyApplied ? 'error' : 'success' + ); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error'); + } finally { + setIsChecking(false); + } + }; return ( = ({ >
-

{project?.title}

+

+ {project?.title} + {project?.alreadyApplied && } +

+ + {project?.alreadyApplied && (project.appliedRefs?.length ?? 0) > 0 && ( +
+

+ Schon im CRM erfasst +

+
    + {project.appliedRefs!.map((ref) => ( +
  • + + {ref.type === 'acquisition' ? 'Akquise' : 'Opportunity'}: {ref.title} + + {ref.consultant && · {ref.consultant}} + {ref.reason &&

    {ref.reason}

    } +
  • + ))} +
+
+ )}
@@ -173,6 +226,14 @@ export const MatchDetailModal: React.FC = ({ > Börse öffnen ↗ + {variant === 'default' && (
+
+ setFlag('applied_check_active', checked)} + label="Abgleich aktiviert" + /> +
+
+ +
+ { + const next = Math.max(1, Number.parseInt(event.target.value, 10) || 1); + set('applied_check_days', String(Math.min(365, next * periodFactor))); + }} + className={`${inputClass} w-28`} + /> + +
+

+ Verglichen wird mit Akquise-Einträgen und Opportunities der letzten {periodDays}{' '} + {periodDays === 1 ? 'Tag' : 'Tage'} (Papierkorb ausgenommen). +

+
+
+
+
apiRequest<{ message: string }>(`/scanner/matches/${id}`, { method: 'DELETE' }), + recheckApplied: (projectId: number) => + apiRequest(`/scanner/projects/${projectId}/applied-check`, { method: 'POST' }), + // ---------- Einstellungen ---------- settings: () => apiRequest>('/scanner/settings'), saveSettings: (input: Partial) =>