From c6011ac0934202523f7fcbb9e3ad43209ab44264 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 13 Sep 2026 00:02:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(nora):=20Runde=2010=20Prompt=203=20?= =?UTF-8?q?=E2=80=94=20Property=20Matching=20gegen=20den=20eigenen=20Besta?= =?UTF-8?q?nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nora arbeitet jetzt mit dem Objektbestand statt mit Quellen. - Lead-Detail auf ganzer Seite statt in der Schublade; darunter der neue Bereich «Property Match». Derselbe Baustein wie bisher, nur mit einem Layoutschalter — keine zweite Detailansicht - «Das hat Livia recherchiert» heisst «Erstelltes Suchprofil» und zeigt lead.semanticSearchProfile. Ein Kasten, nicht zwei: Bestandsleads fallen auf ihren bisherigen Text zurück - Matching-Logik in features/matching/leadPropertyMatcher.ts: Das Suchprofil wird nach Merkmalen gelesen, die darin tatsächlich vorkommen, und gegen das Objekt geprüft. Ein fehlendes Objektmerkmal geht nicht in die Rechnung ein — es gilt als nicht bekannt, nicht als erfüllt. Deterministisch, kein Zufall - Trefferliste ist dieselbe Tabelle wie «Meine Objekte», ergänzt um eine erste Spalte «Match». Die übrigen Spalten werden anteilig gestaucht, damit keine waagrechte Bildlaufleiste entsteht - «Belegte Objekte einbeziehen», Vorgabe aus; Umschalten rechnet neu - MatchRun als eigene Entität mit Persistenz. Genau ein Eintrag je Lauf, auch bei der Übergabe aus Livia — eine Sperre verhindert den zweiten Lauf durch Neuaufbau der Komponente - Aus Livia übergebene Leads starten das Matching einmal; ein normal geöffneter Lead startet nichts - Kennzahlen «Anzahl durchgeführte Matchings» und «Anzahl betroffene Objekte» kommen aus dem laufenden Stand, nicht aus dem Katalog - Aufgaben, Systeme (ERP statt Zefix/SHAB/Webquellen) und Einstellungen auf das Matching zugeschnitten - Info-Knopf erklärt den Match-Wert entlang der tatsächlichen Rechnung Co-Authored-By: Claude Opus 5 --- src/components/market-leads/LeadDetail.tsx | 107 +++--- .../market-leads/PropertyMatchSection.tsx | 257 +++++++++++++ src/components/market-leads/index.ts | 1 + src/components/supply/PropertyTable.tsx | 60 ++- src/components/team/AgentMetricsTab.tsx | 63 +++- src/domain/matchRun.ts | 32 ++ .../__tests__/leadPropertyMatcher.test.ts | 202 ++++++++++ src/features/matching/leadPropertyMatcher.ts | 355 ++++++++++++++++++ src/hooks/useMatchRuns.ts | 38 ++ src/mock-data/agents/nora.ts | 304 +++++---------- src/pages/supply/MarketIntelligence.tsx | 86 ++++- src/provider/IMatchRunProvider.ts | 7 + src/provider/MockupMatchRunProvider.ts | 26 ++ src/provider/teamPersistence.ts | 2 + src/services/matchRunService.ts | 73 ++++ 15 files changed, 1338 insertions(+), 275 deletions(-) create mode 100644 src/components/market-leads/PropertyMatchSection.tsx create mode 100644 src/domain/matchRun.ts create mode 100644 src/features/matching/__tests__/leadPropertyMatcher.test.ts create mode 100644 src/features/matching/leadPropertyMatcher.ts create mode 100644 src/hooks/useMatchRuns.ts create mode 100644 src/provider/IMatchRunProvider.ts create mode 100644 src/provider/MockupMatchRunProvider.ts create mode 100644 src/services/matchRunService.ts diff --git a/src/components/market-leads/LeadDetail.tsx b/src/components/market-leads/LeadDetail.tsx index f5adcfc..c22b6c1 100644 --- a/src/components/market-leads/LeadDetail.tsx +++ b/src/components/market-leads/LeadDetail.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import type { ReactNode } from 'react' import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material' import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } from 'lucide-react' import type { MarketLead } from '../../hooks/useMarketLeads' @@ -29,6 +30,17 @@ const LIVIA = agentWorkspaceById('livia')! interface LeadDetailProps { lead: MarketLead + /** + * Ganze Seite statt Schublade (Runde 10, §3.2). + * + * Nur ein Layoutschalter, keine zweite Ansicht: Dieselben Bausteine in + * derselben Reihenfolge, nur ohne eigenen Bildlauf und mit mehr Platz. + * Eine Vollbildfassung daneben zu bauen hiesse, jede künftige Änderung + * zweimal zu machen. + */ + fullPage?: boolean + /** Was unterhalb der Leadangaben erscheint — bei Nora der Property-Match-Bereich. */ + footer?: ReactNode /** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */ showMatching?: boolean /** @@ -45,21 +57,25 @@ interface LeadDetailProps { * Ein neues Signal ist damit eine neue Komponente und startet mit frischem * Zustand, statt ihn in einem Effekt zurücksetzen zu müssen. */ -function LeadDetail({ lead, showMatching = true, onStartMatching }: LeadDetailProps) { +function LeadDetail({ lead, showMatching = true, onStartMatching, fullPage = false, footer }: LeadDetailProps) { return ( ) } -function LeadDetailBody({ lead, showMatching, onStartMatching }: { +function LeadDetailBody({ lead, showMatching, onStartMatching, fullPage, footer }: { lead: MarketLead showMatching: boolean onStartMatching?: (signalId: string) => void + fullPage: boolean + footer?: ReactNode }) { const { signal, matchingProperties } = lead const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type @@ -80,8 +96,13 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: { const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts]) + // Einzige Wahrheit ist `semanticSearchProfile`; die beiden anderen sind der + // Rückfall für Leads, die vor Runde 10 erfasst wurden. + const profilText = signal.semanticSearchProfile ?? signal.aiSummary ?? signal.strategicInterpretation + return ( - + // Auf der Seite scrollt die Seite, in der Schublade die Schublade. + {/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */} @@ -124,19 +145,48 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: { - {/* Die Recherche stammt seit Runde 8 von Livia — das Kästchen trägt - deshalb ihr Gesicht. Der Matching-Teil weiter unten bleibt Noras. */} - {(signal.aiSummary ?? signal.strategicInterpretation) && ( + {/* + Das erstellte Suchprofil (Runde 10, §§2.13B/3.1). + + Ein Kasten, nicht zwei: Bis Runde 9 hiess er «Das hat Livia + recherchiert» und trug ihre Zusammenfassung; seit Runde 10 steht + darin das Nachfrageprofil, mit dem Nora arbeitet. Beides + nebeneinander zu zeigen wären zwei Texte über denselben Bedarf, und + beim ersten Widerspruch wüsste niemand, welcher gilt. + + Der Rückfall auf `aiSummary` ist die Migration für Bestandsleads: + Sie haben kein Profil, aber den Text, der bisher an dieser Stelle + stand — er bleibt sichtbar, statt ein leeres Feld zu hinterlassen. + */} + {profilText && ( - Das hat Livia recherchiert + Erstelltes Suchprofil - - {signal.aiSummary ?? signal.strategicInterpretation} + + {profilText} + + {/* + «Matching starten» übergibt denselben Lead an Nora — über die + Lead-ID, nicht über eine Kopie. Das Kennzeichen im Zustand sagt + Nora, dass der Lauf sofort starten darf; wer den Lead später + normal öffnet, löst nichts von selbst aus (§2.14). + */} + {onStartMatching && !risiko && ( + + )} )} @@ -205,43 +255,6 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: { )} - {/* - Semantisches Suchprofil (Runde 10, §§2.13B/2.14). - - Es steht nur bei Chance-Leads, weil es einen Flächenbedarf beschreibt — - bei einem Risiko gibt es keinen Nachfrager, für den man suchen könnte. - Der Text ist derselbe, den Nora abgleicht; erzeugt wird er einmal, von - Livia. Ein zweiter Text an dieser Stelle wären zwei Aussagen über - denselben Bedarf. - */} - {!risiko && signal.semanticSearchProfile && ( - - - Semantisches Suchprofil - - - {signal.semanticSearchProfile} - - - {/* - «Matching starten» übergibt denselben Lead an Nora — über die - Lead-ID, nicht über eine Kopie. Das Kennzeichen im Zustand sagt - Nora, dass der Lauf sofort starten darf; wer den Lead später - normal öffnet, löst nichts von selbst aus (§2.14). - */} - {onStartMatching && ( - - )} - - )} {/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */} {(signal.confirmedFacts?.length ?? 0) > 0 && ( @@ -339,6 +352,8 @@ function LeadDetailBody({ lead, showMatching, onStartMatching }: { Fehler soll aber überall gleich lauten (§4, §5). */} + + {footer} ) diff --git a/src/components/market-leads/PropertyMatchSection.tsx b/src/components/market-leads/PropertyMatchSection.tsx new file mode 100644 index 0000000..526eadd --- /dev/null +++ b/src/components/market-leads/PropertyMatchSection.tsx @@ -0,0 +1,257 @@ +/** + * Property On — Noras Property-Match-Bereich (Runde 10, §§3.3–3.8). + * + * Sitzt unterhalb der Leadangaben auf der Detailseite und beantwortet genau + * eine Frage: Welche Objekte aus dem eigenen Bestand passen zu diesem Bedarf? + * + * Drei Entscheidungen, die hier sichtbar werden: + * + * 1. **Vorher steht nichts da.** Kein vorab gerechneter Vorschlag, kein + * Beispielergebnis. Ein Matching ist eine Handlung, und was ohne Handlung + * erscheint, hätte niemand veranlasst. + * 2. **Die Liste ist dieselbe wie «Meine Objekte».** Dieselbe Tabelle, + * dieselben Filter, dieselben Spalten — ergänzt um eine erste Spalte + * «Match». Eine eigene Trefferliste zu bauen hiesse, denselben Bestand + * zweimal unterschiedlich darzustellen. + * 3. **Belegte Objekte bleiben aussen vor, bis jemand sie will.** Der Schalter + * dient der Frage, ob für eine vermietete Fläche konkurrierende Nachfrage + * besteht — eine Information, kein Vorgang. + */ + +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Alert, Box, Button, CircularProgress, FormControlLabel, IconButton, + Popover, Switch, Typography, +} from '@mui/material' +import { Info, Target } from 'lucide-react' +import { PropertyFilterBar } from '../supply/PropertyFilterBar' +import type { PropertyTableFilters } from '../supply/PropertyFilterBar' +import { PropertyTable } from '../supply/PropertyTable' +import { ObjectDeepLink } from '../team' +import { useProperties } from '../../hooks/useProperties' +import { useRunMatching } from '../../hooks/useMatchRuns' +import type { MatchLaufErgebnis } from '../../features/matching/leadPropertyMatcher' +import type { FutureSignal } from '../../domain/futureSignal' +import type { Property } from '../../domain/property' +import { isRisiko } from '../../lib/leadSignal' +import { DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' + +const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } } + +/** + * Der Erklärtext zum Match-Wert (§3.8). + * + * Er beschreibt, was `leadPropertyMatcher.ts` tatsächlich tut. Der letzte Satz + * ist der wichtigste: Es gibt keine feste Prozentgewichtung, weil nur die + * Kriterien gerechnet werden, die sich am jeweiligen Objekt prüfen liessen. + */ +const MATCH_INFO = + 'Der Match-Wert zeigt, wie gut das von Livia erstellte Nachfrageprofil mit den verfügbaren Informationen ' + + 'eines Objekts übereinstimmt. Aus dem Suchprofil werden die Anforderungen gelesen, die darin tatsächlich ' + + 'vorkommen — Standort, Fläche, Nutzungsart und, wo das Profil sie nennt, Erreichbarkeit, Parkierung, ' + + 'Ausbaustand und Sichtbarkeit. Dazu kommt immer die Verfügbarkeit. ' + + 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, geht es ' + + 'gar nicht in die Rechnung ein — das Objekt verliert dadurch keine Punkte, gewinnt aber auch keine. ' + + 'Belegte Objekte sind ausgeschlossen, solange sie nicht ausdrücklich einbezogen werden. ' + + 'Es gibt keine feste Prozentgewichtung je Kriterium, weil je Objekt unterschiedlich viel prüfbar ist.' + +function MatchInfoButton() { + const [anker, setAnker] = useState(null) + return ( + <> + setAnker(e.currentTarget)} + sx={{ color: DS_TEXT.muted, ...FOCUS_SX }} + > + + + setAnker(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { maxWidth: 520, p: 2, boxShadow: DS_SHADOW.panel } } }} + > + + {MATCH_INFO} + + + + ) +} + +interface Props { + signal: FutureSignal + /** + * Aus Livias «Matching starten» gekommen (§3.3). Dann läuft das Matching + * genau einmal von selbst — beim normalen Öffnen nie. + */ + autoStart: boolean +} + +export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, autoStart }: Props) { + const { data: properties = [], isLoading } = useProperties() + const runMatching = useRunMatching() + + const [ergebnis, setErgebnis] = useState(null) + const [includeOccupied, setIncludeOccupied] = useState(false) + const [filters, setFilters] = useState({}) + const [selectedId, setSelectedId] = useState(null) + + /** + * Sperre gegen einen zweiten selbsttätigen Lauf. + * + * React baut Komponenten im Entwicklungsmodus doppelt auf, und jeder + * Filterwechsel rendert neu. Ohne diese Sperre stünden im Protokoll zwei + * Einträge für eine Übergabe — und die Kennzahl zählte doppelt (§3.13). + */ + const autoGelaufen = useRef(false) + + const starten = useCallback( + (belegteMit: boolean, trigger: 'USER' | 'LIVIA_HANDOVER') => { + runMatching.mutate( + { signal, properties, options: { includeOccupied: belegteMit }, trigger }, + { onSuccess: (res) => setErgebnis(res.data.ergebnis) }, + ) + }, + [runMatching, signal, properties], + ) + + useEffect(() => { + if (!autoStart || autoGelaufen.current || isLoading || properties.length === 0) return + autoGelaufen.current = true + starten(false, 'LIVIA_HANDOVER') + }, [autoStart, isLoading, properties.length, starten]) + + /** Umschalten rechnet neu — die Frage hat sich geändert, also auch die Antwort. */ + const handleToggleOccupied = useCallback( + (checked: boolean) => { + setIncludeOccupied(checked) + if (ergebnis) starten(checked, 'USER') + }, + [ergebnis, starten], + ) + + /** Match-Werte je Objekt — die Tabelle braucht sie als Karte. */ + const scores = useMemo(() => { + if (!ergebnis) return undefined + return new Map(ergebnis.matches.map(m => [m.property.id, m.score])) + }, [ergebnis]) + + /** + * Die Trefferliste, nach Match absteigend. + * + * Die Filterleiste wirkt darauf wie in «Meine Objekte» — sie schränkt die + * Anzeige ein, ohne den Lauf zu verändern. Wer die Auswahl ändert, sieht + * weniger Zeilen, nicht andere Werte. + */ + const sichtbar = useMemo((): Property[] => { + if (!ergebnis) return [] + const suche = filters.search?.trim().toLowerCase() + return ergebnis.matches + .map(m => m.property) + .filter(p => { + if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false + if (filters.availabilityStatus && p.availabilityStatus !== filters.availabilityStatus) return false + if (suche) { + const heuhaufen = `${p.title} ${p.location.city} ${p.address.street} ${p.currentTenant ?? ''}`.toLowerCase() + if (!heuhaufen.includes(suche)) return false + } + return true + }) + }, [ergebnis, filters]) + + const risiko = isRisiko(signal) + + return ( + + + + Property Match + + + + + + {/* Bei einem Risiko ist die Frage eine andere — das sagt der Text, nicht die Liste. */} + {risiko && ( + + {signal.propertyId + ? 'Negatives Signal mit zugeordnetem Objekt. Das Matching zeigt hier Alternativen für eine mögliche Wiedervermietung.' + : 'Negatives Signal ohne eindeutig zugeordnetes Objekt. Das Matching zeigt, welche Flächen im Bestand zum beschriebenen Bedarf passen würden.'} + {signal.propertyId && ( + + + + )} + + )} + + handleToggleOccupied(checked)} + /> + } + label="Belegte Objekte einbeziehen" + slotProps={{ typography: { variant: 'body2', sx: { color: DS_TEXT.secondary } } }} + sx={{ mb: 1 }} + /> + + {!ergebnis ? ( + + + Noch kein Abgleich durchgeführt. «Matching starten» vergleicht das Suchprofil mit dem + Objektbestand aus «Meine Objekte». + + + ) : ergebnis.matches.length === 0 ? ( + + Kein Objekt im Kandidatenkreis. + {!includeOccupied && ' Mit «Belegte Objekte einbeziehen» wird der Kreis grösser.'} + + ) : ( + + + {`${ergebnis.candidateCount} ${ergebnis.candidateCount === 1 ? 'Objekt' : 'Objekte'} abgeglichen · `} + {`bester Wert ${ergebnis.matches[0].score} %`} + {sichtbar.length !== ergebnis.matches.length && ` · ${sichtbar.length} nach Filter sichtbar`} + + + + + + + )} + + ) +}) diff --git a/src/components/market-leads/index.ts b/src/components/market-leads/index.ts index 818f0ca..cbb4f09 100644 --- a/src/components/market-leads/index.ts +++ b/src/components/market-leads/index.ts @@ -12,3 +12,4 @@ export { LeadSignalTypeBadge } from './LeadSignalTypeBadge' export { LeadChannelFilterBar } from './LeadChannelFilterBar' export { UnifiedLeadList } from './UnifiedLeadList' export { CrmLeadDetail } from './CrmLeadDetail' +export { PropertyMatchSection } from './PropertyMatchSection' diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx index 92b60c2..32622f5 100644 --- a/src/components/supply/PropertyTable.tsx +++ b/src/components/supply/PropertyTable.tsx @@ -22,9 +22,19 @@ import { qualityColor, } from './propertyHelpers' import { DS_SLATE } from '../../lib/ds' +import { matchScoreHex } from '../../lib/utils' interface PropertyTableProps { properties: Property[] + /** + * Match-Werte je Objekt-ID (Runde 10, §3.6). + * + * Ist die Karte gesetzt, bekommt die Tabelle eine erste Spalte «Match». Der + * Rest — Spalten, Sortierung, Zeilenaufbau, Aktionen — bleibt unverändert: + * Noras Trefferliste ist dieselbe Tabelle wie «Meine Objekte», nicht eine + * zweite, die ihr ähnlich sieht. + */ + matchScores?: Map isLoading: boolean isError: boolean selectedId: string | null @@ -40,6 +50,18 @@ const COL_HEADERS = [ 'Datenqualität', 'Aktionen', ] +/** + * Spaltenanteile in Prozent, in der Reihenfolge der Kopfzeile. + * + * Sie ergeben zusammen 100. Kommt die Match-Spalte dazu, werden alle + * anteilig gestaucht, statt die Tabelle zu verbreitern — eine waagrechte + * Bildlaufleiste wäre für eine einzige Kennzahl ein schlechter Tausch. + */ +const COL_WIDTHS = [19, 7, 9, 6, 8, 13, 9, 7, 9, 9, 4] + +/** Platz für «Match» in Prozent. */ +const MATCH_COL_WIDTH = 7 + function LoadingRows() { return ( <> @@ -100,7 +122,11 @@ export function PropertyTable({ onViewDetail, filters, onFiltersChange, + matchScores, }: PropertyTableProps) { + const showMatch = matchScores !== undefined + const skalierung = showMatch ? (100 - MATCH_COL_WIDTH) / 100 : 1 + if (isError) { return Objekte konnten nicht geladen werden. } @@ -140,20 +166,18 @@ export function PropertyTable({ }} > - {/* Objekt */} - {/* Typ */} - {/* Standort */} - {/* Fläche */} - {/* Miete */} - {/* Aktueller Mieter */} - {/* Mietlaufzeit */} - {/* Breakoutoption */} - {/* Breakoutoption Zeitpunkt */} - {/* Datenqualität */} - {/* Aktionen */} + {showMatch && } + {COL_WIDTHS.map((w, i) => ( + + ))} + {showMatch && ( + + Match + + )} Objekt Typ Standort @@ -172,7 +196,7 @@ export function PropertyTable({ ) : properties.length === 0 ? ( - + Keine Objekte gefunden. @@ -200,6 +224,18 @@ export function PropertyTable({ '&:hover': { bgcolor: isSelected ? 'rgba(30,58,95,0.08)' : 'rgba(0,0,0,0.02)' }, }} > + {/* Match — erste Spalte, nur in Noras Trefferliste (§3.6) */} + {showMatch && ( + + + {matchScores!.get(p.id) ?? 0} % + + + )} + {/* Objekt */} diff --git a/src/components/team/AgentMetricsTab.tsx b/src/components/team/AgentMetricsTab.tsx index 72954c3..41d3146 100644 --- a/src/components/team/AgentMetricsTab.tsx +++ b/src/components/team/AgentMetricsTab.tsx @@ -1,16 +1,28 @@ -import { memo } from 'react' +import { memo, useMemo } from 'react' import { Box, Typography } from '@mui/material' import type { AgentMetric, TeamAgent } from '../../domain/teamAgent' import { EmptyState } from '../ui' +import { useMatchRuns } from '../../hooks/useMatchRuns' +import { useFutureSignals } from '../../hooks/useFutureSignals' +import { isRisiko } from '../../lib/leadSignal' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' /** * Reiter «Kennzahlen». * - * Zeigt ausschliesslich die Kennzahlen, die im Personalblatt des Mitarbeitenden - * bereits hinterlegt sind. Es werden keine Werte berechnet, hochgerechnet oder - * ergänzt — was der Katalog nicht führt, steht hier auch nicht. + * Zeigt die Kennzahlen, die das Personalblatt führt. Es wird nichts + * hochgerechnet und nichts ergänzt — was der Katalog nicht führt, steht hier + * auch nicht. + * + * Ausnahme seit Runde 10 (§3.11): Noras beide Kennzahlen kommen aus dem + * laufenden Stand der Anwendung und nicht aus dem Katalog. Ein fester Wert + * wäre dort eine Behauptung — die eine Zahl zählt tatsächlich ausgeführte + * Matching-Läufe, die andere tatsächlich zugeordnete Objekte. */ + +/** Kennungen, deren Wert zur Laufzeit entsteht. */ +const MATCH_RUNS_METRIC = 'nora-metric-match-runs' +const AFFECTED_OBJECTS_METRIC = 'nora-metric-affected-objects' const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) { return ( { + const laeufe = matchRuns.length + const betroffene = new Set( + signals.filter(sig => isRisiko(sig) && sig.propertyId).map(sig => sig.propertyId!), + ).size + + return agent.metrics.map((m) => { + if (m.id === MATCH_RUNS_METRIC) { + return { + ...m, + value: String(laeufe), + hint: laeufe === 0 + ? 'Noch kein Matching ausgeführt. Die Zahl steigt nur durch einen tatsächlichen Lauf.' + : `Zuletzt am ${new Date(matchRuns[0].timestamp).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })} für «${matchRuns[0].leadTitle}».`, + } + } + if (m.id === AFFECTED_OBJECTS_METRIC) { + return { + ...m, + value: String(betroffene), + hint: betroffene === 0 + ? 'Derzeit ist keinem Risikosignal ein Objekt aus dem Bestand eindeutig zugeordnet.' + : m.hint, + } + } + return m + }) + }, [agent.metrics, matchRuns, signals]) + + if (metrics.length === 0) { return ( - {agent.metrics.map((metric) => ( + {metrics.map((metric) => ( ))} diff --git a/src/domain/matchRun.ts b/src/domain/matchRun.ts new file mode 100644 index 0000000..247b43c --- /dev/null +++ b/src/domain/matchRun.ts @@ -0,0 +1,32 @@ +/** + * Property On — ein durchgeführter Matching-Lauf (Runde 10, §§3.11/3.13). + * + * Der Lauf wird festgehalten, weil zwei Anzeigen davon abhängen: das Protokoll + * und die Kennzahl «Anzahl durchgeführte Matchings». Beide sollen zählen, was + * tatsächlich passiert ist — nicht, wie oft jemand eine Seite geöffnet hat. + * + * Deshalb ist ein MatchRun eine eigene Entität und kein Nebeneffekt des + * Renderns: Ein Wert, der aus dem Rendern entsteht, wächst bei jedem + * Neuaufbau der Komponente, und niemand könnte dem Zähler noch trauen. + */ + +export interface MatchRun { + id: string + /** Der Lead, für den gematcht wurde — dieselbe Kennung wie bei Livia. */ + leadId: string + /** Firma oder Titel des Leads, damit das Protokoll ohne Nachschlagen lesbar ist. */ + leadTitle: string + timestamp: string + /** Wie viele Objekte in den Kandidatenkreis kamen. */ + candidateCount: number + /** Ob belegte Objekte mitgeprüft wurden. */ + includedOccupied: boolean + /** Bester erreichter Wert — die Zahl, die den Lauf zusammenfasst. */ + topScore: number + /** + * Ob der Lauf aus Livias «Matching starten» kam oder auf Noras Seite + * ausgelöst wurde. Beides zählt gleich, aber beim Nachvollziehen ist der + * Unterschied die erste Frage. + */ + trigger: 'LIVIA_HANDOVER' | 'USER' +} diff --git a/src/features/matching/__tests__/leadPropertyMatcher.test.ts b/src/features/matching/__tests__/leadPropertyMatcher.test.ts new file mode 100644 index 0000000..40fc9e6 --- /dev/null +++ b/src/features/matching/__tests__/leadPropertyMatcher.test.ts @@ -0,0 +1,202 @@ +/** + * Noras Abgleich von Nachfrageprofil und Objektbestand (Runde 10, §3.7). + * + * Die Tests halten drei Zusagen fest, die man dem Prozentwert sonst nicht + * ansieht: Er ist reproduzierbar, er nimmt fehlende Objektangaben nicht als + * erfüllt an, und belegte Objekte sind nur dabei, wenn jemand sie will. + */ + +import { describe, expect, it } from 'vitest' +import { + MatchAvailability, + bewerteObjekt, + fuehreMatchingAus, + leseNachfrageprofil, + verfuegbarkeitVon, +} from '../leadPropertyMatcher' +import { AssetType, AvailabilityStatus, ResultType, RiskLevel } from '../../../domain/enums' +import type { Property } from '../../../domain/property' +import type { FutureSignal } from '../../../domain/futureSignal' +import { SignalType } from '../../../domain/enums' + +function objekt(overrides: Partial = {}): Property { + return { + id: 'p1', + title: 'Gewerbefläche Zürich West', + assetType: AssetType.OFFICE, + resultType: ResultType.VERIFIED_PORTFOLIO, + location: { city: 'Zürich', district: 'Kreis 5', country: 'CH' }, + address: { street: 'Hardstrasse', houseNumber: '10', postalCode: '8005', city: 'Zürich', country: 'CH' }, + areaSqm: 1200, + rentPricePerSqm: 320, + availabilityDate: '2026-10-01', + availabilityStatus: AvailabilityStatus.AVAILABLE_NOW, + sourceType: 'MANUAL', + confidenceScore: 0.9, + dataQuality: { score: 0.9, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] }, + riskLevel: RiskLevel.LOW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } as Property +} + +function signal(overrides: Partial = {}): FutureSignal { + return { + id: 's1', + signalType: SignalType.EXPANSION, + companyName: 'Muster AG', + locationHint: 'Zürich', + probability: 0.8, + confidenceScore: 0.8, + timeHorizonMonths: 9, + source: { type: 'PRESS', credibility: 'HIGH' }, + sensitivityLevel: 'PUBLIC', + disclaimer: '', + riskLevel: RiskLevel.LOW, + isVerified: false, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + semanticSearchProfile: + 'Für die Muster AG wird aufgrund der angekündigten Expansion ein zusätzlicher Bürostandort im Raum Zürich gesucht. ' + + 'Erwartet wird eine Fläche von rund 1200 m². Wichtig ist eine gute Anbindung an den öffentlichen Verkehr. ' + + 'Nicht ableitbar aus der Quelle sind Budget und gewünschter Ausbaustand.', + ...overrides, + } as FutureSignal +} + +describe('verfuegbarkeitVon', () => { + it('fasst frei und bald frei zusammen', () => { + expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.AVAILABLE_NOW }))) + .toBe(MatchAvailability.FREI) + expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.AVAILABLE_SOON }))) + .toBe(MatchAvailability.FREI) + }) + + it('unterscheidet «unbekannt» von «frei»', () => { + expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.UNKNOWN }))) + .toBe(MatchAvailability.UNBEKANNT) + }) +}) + +describe('leseNachfrageprofil', () => { + it('liest Ort, Fläche, Nutzung und genannte Anforderungen aus dem Profil', () => { + const profil = leseNachfrageprofil(signal()) + + expect(profil.orte).toContain('zürich') + expect(profil.flaecheSqm).toBe(1200) + expect(profil.nutzung).toContain('buero') + expect(profil.anforderungen.oev).toBe(true) + }) + + it('erfindet keine Anforderung, die im Profil nicht steht', () => { + const profil = leseNachfrageprofil(signal()) + + expect(profil.anforderungen.parkierung).toBe(false) + expect(profil.anforderungen.sichtbarkeit).toBe(false) + }) + + it('nimmt die erfasste Flächenschätzung vor der aus dem Fliesstext', () => { + const profil = leseNachfrageprofil(signal({ areaSqmEstimate: 800 })) + + expect(profil.flaecheSqm).toBe(800) + }) +}) + +describe('bewerteObjekt', () => { + it('gibt einem passenden Objekt einen hohen Wert', () => { + const { score } = bewerteObjekt(objekt(), leseNachfrageprofil(signal())) + + expect(score).toBeGreaterThanOrEqual(70) + }) + + it('straft den falschen Ort ab', () => { + const profil = leseNachfrageprofil(signal()) + const zuerich = bewerteObjekt(objekt(), profil).score + const genf = bewerteObjekt( + objekt({ + location: { city: 'Genf', country: 'CH' }, + address: { street: 'Rue du Rhône', houseNumber: '1', postalCode: '1204', city: 'Genf', country: 'CH' }, + }), + profil, + ).score + + expect(genf).toBeLessThan(zuerich) + }) + + it('straft eine stark abweichende Fläche ab', () => { + const profil = leseNachfrageprofil(signal()) + const passend = bewerteObjekt(objekt({ areaSqm: 1200 }), profil).score + const winzig = bewerteObjekt(objekt({ areaSqm: 120 }), profil).score + + expect(winzig).toBeLessThan(passend) + }) + + it('nimmt ein fehlendes Merkmal nicht als erfüllt an', () => { + const profil = leseNachfrageprofil(signal()) + const kriterium = bewerteObjekt(objekt(), profil).kriterien.find(k => k.label === 'Erreichbarkeit') + + // Das Objekt hat keinen Anbindungswert — das Kriterium wird geführt, aber + // nicht bewertet. Ein `1` an dieser Stelle wäre eine erfundene Zusage. + expect(kriterium?.erfuellung).toBeNull() + }) + + it('bewertet nur Kriterien, die das Profil überhaupt nennt', () => { + const knapp = signal({ semanticSearchProfile: 'Die Muster AG expandiert im Raum Zürich.' }) + const labels = bewerteObjekt(objekt(), leseNachfrageprofil(knapp)).kriterien.map(k => k.label) + + expect(labels).toContain('Standort') + expect(labels).not.toContain('Parkierung') + expect(labels).not.toContain('Sichtbarkeit') + }) + + it('liefert für dieselbe Eingabe denselben Wert', () => { + const profil = leseNachfrageprofil(signal()) + + expect(bewerteObjekt(objekt(), profil).score).toBe(bewerteObjekt(objekt(), profil).score) + }) +}) + +describe('fuehreMatchingAus', () => { + const bestand = [ + objekt({ id: 'frei-zuerich', areaSqm: 1200 }), + objekt({ id: 'frei-klein', areaSqm: 150 }), + objekt({ id: 'belegt', availabilityStatus: AvailabilityStatus.OCCUPIED, currentTenant: 'Alt AG' }), + ] + + it('lässt belegte Objekte standardmässig aussen vor', () => { + const { matches, candidateCount } = fuehreMatchingAus(signal(), bestand, { includeOccupied: false }) + + expect(candidateCount).toBe(2) + expect(matches.map(m => m.property.id)).not.toContain('belegt') + }) + + it('nimmt belegte Objekte auf Wunsch dazu', () => { + const { matches, candidateCount } = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }) + + expect(candidateCount).toBe(3) + expect(matches.map(m => m.property.id)).toContain('belegt') + }) + + it('sortiert nach Match absteigend', () => { + const { matches } = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }) + const werte = matches.map(m => m.score) + + expect([...werte].sort((a, b) => b - a)).toEqual(werte) + expect(matches[0].property.id).toBe('frei-zuerich') + }) + + it('ist stabil — zwei Läufe ergeben dieselbe Reihenfolge', () => { + const a = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }).matches.map(m => m.property.id) + const b = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }).matches.map(m => m.property.id) + + expect(a).toEqual(b) + }) + + it('liefert bei leerem Bestand eine leere Liste, keinen Fehler', () => { + const { matches, candidateCount } = fuehreMatchingAus(signal(), [], { includeOccupied: false }) + + expect(matches).toEqual([]) + expect(candidateCount).toBe(0) + }) +}) diff --git a/src/features/matching/leadPropertyMatcher.ts b/src/features/matching/leadPropertyMatcher.ts new file mode 100644 index 0000000..43d09c5 --- /dev/null +++ b/src/features/matching/leadPropertyMatcher.ts @@ -0,0 +1,355 @@ +/** + * Property On — Noras Abgleich von Nachfrageprofil und Objektbestand + * (Runde 10, §3.7). + * + * **Warum diese Datei und nicht `scoreCalculator.ts`:** Der bestehende Rechner + * bewertet ein `Need` — ein strukturiertes Bedarfsprofil mit erfassten + * Zahlenfeldern und einem Gewichtungsprofil. Livias Lead hat das nicht. Er hat + * einen Fliesstext, den ein Modell aus einer Zeitungsmeldung abgeleitet hat, + * und ein paar Angaben, die dabei abfielen. Diesen Text in ein `Need` zu + * pressen hiesse, Zahlen zu erfinden, die niemand erhoben hat. + * + * **Wie stattdessen gerechnet wird:** Das Suchprofil wird nach Merkmalen + * durchsucht, die darin tatsächlich vorkommen — eine Ortsangabe, eine + * Flächenangabe, eine Nutzungsart, Anforderungen an Verkehr, Parkierung oder + * Ausbau. Jedes gefundene Merkmal wird gegen das Objekt geprüft und trägt zum + * Ergebnis bei. Was im Profil nicht vorkommt, wird nicht bewertet; was im + * Objekt fehlt, gilt als **nicht bekannt** und nicht als erfüllt. + * + * **Deterministisch.** Derselbe Lead und derselbe Bestand ergeben denselben + * Wert. Es gibt keinen Zufall und keine Gewichtung, die nicht unten im Code + * steht — deshalb kann der Infotext zum Match-Wert auch beschreiben, was + * wirklich passiert. + */ + +import { AvailabilityStatus } from '../../domain/enums' +import type { Property } from '../../domain/property' +import type { FutureSignal } from '../../domain/futureSignal' + +// ── Verfügbarkeit ───────────────────────────────────────────────────────────── + +export const MatchAvailability = { + /** Sofort oder demnächst frei — der Normalfall für ein Matching. */ + FREI: 'FREI', + /** Vermietet. Nur auf ausdrücklichen Wunsch im Kandidatenkreis (§3.5). */ + BELEGT: 'BELEGT', + /** Der Bestand sagt nichts dazu — das ist etwas anderes als «frei». */ + UNBEKANNT: 'UNBEKANNT', +} as const +export type MatchAvailability = typeof MatchAvailability[keyof typeof MatchAvailability] + +/** + * Verfügbarkeit eines Objekts, normalisiert (§3.4). + * + * Der Bestand führt fünf Zustände, für das Matching zählen drei. Die + * Zusammenfassung steht hier einmal, damit nicht jede Stelle ihre eigene + * Auslegung von «bald frei» entwickelt. + */ +export function verfuegbarkeitVon(p: Property): MatchAvailability { + switch (p.availabilityStatus) { + case AvailabilityStatus.AVAILABLE_NOW: + case AvailabilityStatus.AVAILABLE_SOON: + case AvailabilityStatus.FUTURE_SIGNAL: + return MatchAvailability.FREI + case AvailabilityStatus.OCCUPIED: + return MatchAvailability.BELEGT + default: + return MatchAvailability.UNBEKANNT + } +} + +// ── Merkmale aus dem Suchprofil lesen ───────────────────────────────────────── + +/** Was sich aus einem Nachfrageprofil überhaupt herauslesen liess. */ +export interface Nachfrageprofil { + /** Ortsnamen, die im Profil vorkommen — klein geschrieben. */ + orte: string[] + /** Gesuchte Fläche in m², falls das Profil eine nennt. */ + flaecheSqm?: number + /** Schlagworte zur Nutzungsart, klein geschrieben. */ + nutzung: string[] + /** Weiche Anforderungen, die im Profil ausdrücklich stehen. */ + anforderungen: { + oev: boolean + parkierung: boolean + ausbau: boolean + sichtbarkeit: boolean + } +} + +/** + * Städte und Regionen, die im Bestand vorkommen. + * + * Bewusst eine feste Liste und keine Namenserkennung: Der Bestand ist der + * Schweizer Gewerbemarkt, und ein Freitext-Abgleich auf beliebige + * Grossbuchstaben würde bei jedem Firmennamen anschlagen. + */ +const ORTE = [ + 'zürich', 'zurich', 'winterthur', 'basel', 'bern', 'luzern', 'zug', 'st. gallen', 'st.gallen', + 'lausanne', 'genf', 'genève', 'aarau', 'baden', 'olten', 'schlieren', 'dietikon', 'opfikon', + 'wallisellen', 'dübendorf', 'wetzikon', 'uster', 'thalwil', 'horgen', 'kloten', 'regensdorf', + 'pratteln', 'muttenz', 'allschwil', 'reinach', 'liestal', 'rotkreuz', 'cham', 'baar', +] + +const NUTZUNG_WORTE: Record = { + buero: ['büro', 'buero', 'office', 'verwaltung', 'arbeitsplätze', 'arbeitsplatz'], + lager: ['lager', 'logistik', 'distribution', 'umschlag', 'warenlager'], + produktion: ['produktion', 'fertigung', 'werkstatt', 'industrie', 'montage'], + verkauf: ['verkauf', 'retail', 'ladenfläche', 'filiale', 'showroom', 'detailhandel'], + gewerbe: ['gewerbe', 'gewerbefläche', 'gewerbestandort'], +} + +/** + * Merkmale aus einem Nachfrageprofil herauslesen. + * + * Zusätzlich zum Fliesstext gehen die strukturierten Angaben des Signals ein, + * soweit vorhanden — Ortshinweis und geschätzte Fläche. Sie sind verlässlicher + * als das, was sich aus Sätzen herauslesen lässt, und gehen deshalb vor. + */ +export function leseNachfrageprofil(signal: FutureSignal): Nachfrageprofil { + const text = [ + signal.semanticSearchProfile ?? '', + signal.aiSummary ?? '', + signal.title ?? '', + signal.locationHint, + ].join(' ').toLowerCase() + + const orte = ORTE.filter(o => text.includes(o)) + // Der erfasste Ortshinweis zählt immer mit, auch wenn er nicht in der Liste steht. + const ortHinweis = signal.locationHint.split(/[,/(]/)[0].trim().toLowerCase() + if (ortHinweis && ortHinweis.length > 2 && !orte.includes(ortHinweis)) orte.push(ortHinweis) + + const nutzung = Object.entries(NUTZUNG_WORTE) + .filter(([, worte]) => worte.some(w => text.includes(w))) + .map(([schluessel]) => schluessel) + + return { + orte, + flaecheSqm: signal.areaSqmEstimate ?? flaecheAusText(text), + nutzung, + anforderungen: { + oev: /öffentlich(en)? verkehr|öv|bahnhof|s-bahn|tram|verkehrsanbindung|erreichbarkeit/.test(text), + parkierung: /parkplat|parkier|parking|anlieferung|lastwagen|rampe/.test(text), + ausbau: /ausbau|ausgebaut|bezugsbereit|infrastruktur|serverraum|klimatisier/.test(text), + sichtbarkeit: /sichtbar|passanten|frequenz|schaufenster|repräsentativ|prestige/.test(text), + }, + } +} + +/** Eine Flächenangabe aus dem Fliesstext — nur, wenn sie eindeutig als solche dasteht. */ +function flaecheAusText(text: string): number | undefined { + const treffer = /(\d[\d'’.\s]{1,8})\s*(?:m²|m2|quadratmeter)/.exec(text) + if (!treffer) return undefined + const zahl = Number(treffer[1].replace(/[^\d]/g, '')) + return Number.isFinite(zahl) && zahl >= 20 ? zahl : undefined +} + +// ── Bewertung ───────────────────────────────────────────────────────────────── + +/** Ein geprüftes Merkmal — das ist die Erklärung hinter dem Prozentwert. */ +export interface MatchKriterium { + label: string + /** 0–1, oder null wenn das Objekt dazu nichts sagt. */ + erfuellung: number | null + /** Wie stark das Kriterium zählt, wenn es geprüft werden konnte. */ + gewicht: number + /** Ein Satz, der den Wert erklärt. */ + hinweis: string +} + +export interface PropertyMatch { + property: Property + /** 0–100. */ + score: number + kriterien: MatchKriterium[] + verfuegbarkeit: MatchAvailability +} + +/** Gewichte der Kriterien. Sie stehen hier, damit der Infotext sie nicht erfinden muss. */ +const GEWICHT = { + ort: 3, + flaeche: 3, + nutzung: 2, + verfuegbarkeit: 2, + oev: 1, + parkierung: 1, + ausbau: 1, + sichtbarkeit: 1, +} as const + +/** Wie nah zwei Flächen beieinander liegen — 1 bei Gleichheit, 0 ab Faktor drei. */ +function flaechenNaehe(objekt: number, gesucht: number): number { + if (objekt <= 0 || gesucht <= 0) return 0 + const verhaeltnis = objekt >= gesucht ? objekt / gesucht : gesucht / objekt + if (verhaeltnis <= 1.25) return 1 + if (verhaeltnis >= 3) return 0 + return 1 - (verhaeltnis - 1.25) / 1.75 +} + +function normiert(wert: number | undefined, max: number): number | null { + if (wert == null) return null + return Math.max(0, Math.min(1, wert / max)) +} + +/** + * Ein Objekt gegen ein Nachfrageprofil bewerten. + * + * Gerechnet wird nur über die Kriterien, die tatsächlich geprüft werden + * konnten. Ein Objekt ohne Angabe zur Parkierung verliert dadurch keine + * Punkte — es bekommt aber auch keine. Der Prozentwert sagt damit «so gut + * passt, was wir wissen», nicht «so gut passt es». + */ +export function bewerteObjekt(p: Property, profil: Nachfrageprofil): PropertyMatch { + const kriterien: MatchKriterium[] = [] + const verfuegbarkeit = verfuegbarkeitVon(p) + + // ── Standort ── + if (profil.orte.length > 0) { + const objektOrte = [p.location.city, p.location.district, p.location.region, p.address.city] + .filter(Boolean).map(o => o!.toLowerCase()) + const treffer = profil.orte.some(o => objektOrte.some(z => z.includes(o) || o.includes(z))) + kriterien.push({ + label: 'Standort', + erfuellung: treffer ? 1 : 0, + gewicht: GEWICHT.ort, + hinweis: treffer + ? `${p.location.city} liegt im gesuchten Raum.` + : `${p.location.city} liegt ausserhalb des im Profil genannten Raums.`, + }) + } + + // ── Fläche ── + if (profil.flaecheSqm) { + const naehe = flaechenNaehe(p.areaSqm, profil.flaecheSqm) + kriterien.push({ + label: 'Fläche', + erfuellung: naehe, + gewicht: GEWICHT.flaeche, + hinweis: `${p.areaSqm.toLocaleString('de-CH')} m² gegenüber gesuchten rund ${profil.flaecheSqm.toLocaleString('de-CH')} m².`, + }) + } + + // ── Nutzungsart ── + if (profil.nutzung.length > 0) { + const objektNutzung = `${p.assetType} ${p.hardFacts?.usageType ?? ''} ${p.title}`.toLowerCase() + const treffer = profil.nutzung.some(n => + objektNutzung.includes(n) || (NUTZUNG_WORTE[n] ?? []).some(w => objektNutzung.includes(w))) + kriterien.push({ + label: 'Nutzungsart', + erfuellung: treffer ? 1 : 0, + gewicht: GEWICHT.nutzung, + hinweis: treffer + ? 'Die Nutzungsart des Objekts entspricht dem Profil.' + : 'Die Nutzungsart weicht vom Profil ab.', + }) + } + + // ── Verfügbarkeit ── + kriterien.push({ + label: 'Verfügbarkeit', + erfuellung: verfuegbarkeit === MatchAvailability.FREI ? 1 + : verfuegbarkeit === MatchAvailability.BELEGT ? 0 + : null, + gewicht: GEWICHT.verfuegbarkeit, + hinweis: verfuegbarkeit === MatchAvailability.FREI + ? 'Frei oder demnächst frei werdend.' + : verfuegbarkeit === MatchAvailability.BELEGT + ? 'Derzeit vermietet.' + : 'Der Bestand sagt nichts zur Verfügbarkeit.', + }) + + // ── Weiche Anforderungen, nur wenn das Profil sie nennt ── + if (profil.anforderungen.oev) { + const wert = normiert(p.hardFacts?.publicTransportScore ?? p.softFactors?.commuterAccessScore, 10) + kriterien.push({ + label: 'Erreichbarkeit', + erfuellung: wert, + gewicht: GEWICHT.oev, + hinweis: wert == null ? 'Zur Erreichbarkeit ist nichts erfasst.' : `Anbindungswert ${Math.round(wert * 100)} von 100.`, + }) + } + if (profil.anforderungen.parkierung) { + const plaetze = p.hardFacts?.parking ?? p.softFactors?.parkingSpots + kriterien.push({ + label: 'Parkierung', + erfuellung: plaetze == null ? null : plaetze > 0 ? 1 : 0, + gewicht: GEWICHT.parkierung, + hinweis: plaetze == null + ? 'Zur Parkierung ist nichts erfasst.' + : `${plaetze} Parkplätze erfasst.`, + }) + } + if (profil.anforderungen.ausbau) { + const stufe = p.hardFacts?.fitOut + const wert = stufe == null ? null : stufe === 'PREMIUM' ? 1 : stufe === 'FULL' ? 0.8 : stufe === 'BASIC' ? 0.5 : 0.2 + kriterien.push({ + label: 'Ausbaustand', + erfuellung: wert, + gewicht: GEWICHT.ausbau, + hinweis: stufe == null ? 'Zum Ausbaustand ist nichts erfasst.' : `Ausbaustand ${stufe}.`, + }) + } + if (profil.anforderungen.sichtbarkeit) { + const wert = normiert(p.softFactors?.visibilityScore ?? p.softFactors?.prestigeScore, 10) + kriterien.push({ + label: 'Sichtbarkeit', + erfuellung: wert, + gewicht: GEWICHT.sichtbarkeit, + hinweis: wert == null ? 'Zur Sichtbarkeit ist nichts erfasst.' : `Sichtbarkeitswert ${Math.round(wert * 100)} von 100.`, + }) + } + + // Nur geprüfte Kriterien zählen. Ein Objekt, zu dem gar nichts bekannt ist, + // bekommt 0 — nicht 100, weil «nichts spricht dagegen». + let summe = 0 + let gewichte = 0 + for (const k of kriterien) { + if (k.erfuellung == null) continue + summe += k.erfuellung * k.gewicht + gewichte += k.gewicht + } + const score = gewichte === 0 ? 0 : Math.round((summe / gewichte) * 100) + + return { property: p, score, kriterien, verfuegbarkeit } +} + +export interface MatchLaufOptionen { + /** Belegte Objekte in den Kandidatenkreis nehmen (§3.5). Vorgabe: nein. */ + includeOccupied: boolean +} + +export interface MatchLaufErgebnis { + matches: PropertyMatch[] + /** Wie viele Objekte überhaupt geprüft wurden. */ + candidateCount: number + profil: Nachfrageprofil +} + +/** + * Der Lauf über den gesamten Bestand. + * + * Ausgeschlossen wird nur, was eindeutig ausgeschlossen gehört: belegte + * Objekte, solange sie nicht ausdrücklich gewünscht sind. Alles andere wird + * bewertet und sortiert — auch Schlechtpassendes, denn «kein Treffer» ist eine + * andere Aussage als «nichts geprüft». + */ +export function fuehreMatchingAus( + signal: FutureSignal, + properties: Property[], + optionen: MatchLaufOptionen, +): MatchLaufErgebnis { + const profil = leseNachfrageprofil(signal) + + const kandidaten = properties.filter(p => { + const v = verfuegbarkeitVon(p) + if (v === MatchAvailability.BELEGT && !optionen.includeOccupied) return false + return true + }) + + const matches = kandidaten + .map(p => bewerteObjekt(p, profil)) + // Stabil sortiert: bei gleichem Wert entscheidet die Kennung, nicht der Zufall. + .sort((a, b) => (b.score - a.score) || a.property.id.localeCompare(b.property.id)) + + return { matches, candidateCount: kandidaten.length, profil } +} diff --git a/src/hooks/useMatchRuns.ts b/src/hooks/useMatchRuns.ts new file mode 100644 index 0000000..cc722ef --- /dev/null +++ b/src/hooks/useMatchRuns.ts @@ -0,0 +1,38 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { matchRunService } from '../services/matchRunService' +import type { RunMatchingInput } from '../services/matchRunService' +import { STALE_SIGNALS } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' + +const KEY = ['matchRuns'] + +/** Die durchgeführten Läufe — Grundlage für Protokoll und Kennzahl (§§3.11/3.13). */ +export function useMatchRuns() { + return useQuery({ + queryKey: KEY, + queryFn: () => matchRunService.getAll(), + staleTime: STALE_SIGNALS, + select: (res) => res.data, + }) +} + +/** + * Einen Lauf ausführen. + * + * Als Mutation und nicht als Query: Ein Matching ist eine Handlung, kein + * Zustand, der sich von selbst herstellt. Genau darum zählt die Kennzahl + * korrekt — eine Query würde bei jedem Neuaufbau der Seite erneut laufen. + */ +export function useRunMatching() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: RunMatchingInput) => matchRunService.run(input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: KEY }) + void queryClient.invalidateQueries({ queryKey: ['agentProtocol'] }) + }, + onError: () => { + useToastStore.getState().showToast('Das Matching konnte nicht ausgeführt werden.', 'error') + }, + }) +} diff --git a/src/mock-data/agents/nora.ts b/src/mock-data/agents/nora.ts index 7d6c562..d86860a 100644 --- a/src/mock-data/agents/nora.ts +++ b/src/mock-data/agents/nora.ts @@ -71,76 +71,43 @@ export const noraAgent: TeamAgent = { 'Vorbereitung von Wochen-Mail, Lead-Liste und monatlicher Markteinschätzung als Vorschlag', ], + /* + * Runde 10, §3.10: Noras Aufgabenliste beschreibt jetzt, was sie tatsächlich + * tut — Nachfrageprofile gegen den Bestand abgleichen. Beobachten, + * klassifizieren, Quellen prüfen und der Wochenversand sind zu Livia + * gewandert; sie liest die Quellen, Nora arbeitet mit dem, was daraus wird. + */ tasks: [ { - id: 'nora-task-01', - title: 'Quellen und Feeds simuliert überwachen', + id: 'nora-task-10', + title: 'Internes Matching durchführen', description: - 'Sichtet die freigegebenen öffentlichen Quellen und die simulierten Feeds zu den eingestellten Regionen und Segmenten. Neue Meldungen werden mit Quelle, Datum und Fundort erfasst; es wird nichts veröffentlicht und nichts weitergegeben.', + 'Wertet Nachfrageprofile aus und gleicht sie mit der internen Objektdatenbank ab. Grundlage ist derselbe Objektbestand wie in «Meine Objekte» — es gibt keine zweite Objektliste für das Matching.', enabled: true, - schedule: 'täglich 06:00, zusätzlich stündlich zwischen 08:00 und 18:00', + schedule: 'auf Anforderung, bei jedem Matching-Lauf', requiresApproval: false, - dependsOnSystem: AgentSystemType.PUBLIC_WEB, + dependsOnSystem: AgentSystemType.IMMOTOP2, }, { - id: 'nora-task-02', - title: 'Marktsignale klassifizieren', + id: 'nora-task-11', + title: 'Match-Score analysieren und zuordnen', description: - 'Ordnet jede erfasste Meldung einer Signalart zu — Umzug, Expansion, Verkleinerung, Neugründung oder Filialschliessung — und hält fest, welche Textstelle der Quelle die Einordnung stützt.', + 'Bewertet die Übereinstimmung zwischen Nachfrageprofil und Objektmerkmalen und stellt sie als nachvollziehbaren Match-Wert dar. Merkmale, zu denen der Bestand nichts sagt, gelten als nicht bekannt und nicht als erfüllt.', enabled: true, - schedule: 'bei Eingang einer neuen Meldung', + schedule: 'im Anschluss an jeden Matching-Lauf', + requiresApproval: false, + dependsOnSystem: AgentSystemType.IMMOTOP2, + }, + { + id: 'nora-task-12', + title: 'Potenzielle Objekte für Matching darstellen', + description: + 'Bereitet geeignete verfügbare, bald verfügbare und — wenn ausdrücklich aktiviert — belegte Objekte gemäss den definierten Matchingparametern auf. Belegte Objekte dienen der Frage nach konkurrierender Nachfrage, nicht der Kündigung eines Mietverhältnisses.', + enabled: true, + schedule: 'im Anschluss an jeden Matching-Lauf', requiresApproval: false, dependsOnSystem: AgentSystemType.WORKSPACE, }, - { - id: 'nora-task-03', - title: 'Konfidenz vergeben', - description: - 'Vergibt jedem Signal einen Konfidenzwert in Prozent. Grundlage sind Verlässlichkeit der Quelle, Aktualität der Meldung und ob sich dieselbe Information in einer zweiten Quelle bestätigt.', - enabled: true, - schedule: 'bei jeder Klassifizierung', - requiresApproval: false, - }, - { - id: 'nora-task-05', - title: 'Signale Regionen und Objekten zuordnen', - description: - 'Verknüpft jedes verifizierte Signal mit der betroffenen Region und, sofern Adresse oder Firmensitz eindeutig belegt sind, mit einem konkreten Objekt im Portfolio. Bleibt die Zuordnung unklar, wird sie offen gelassen und als offen ausgewiesen.', - enabled: true, - schedule: 'bei jedem verifizierten Signal', - requiresApproval: false, - dependsOnSystem: AgentSystemType.WORKSPACE, - }, - { - id: 'nora-task-06', - title: 'Wochen-Mail dienstags um 08:00 Uhr erstellen', - description: - 'Stellt die wichtigsten Signale der vergangenen Woche nach Region und Segment zusammen, jeweils mit Konfidenz und Quellenangabe. Der Entwurf geht als Vorschlag an die Marktbeobachtung und wird erst nach Freigabe an die eingetragenen Empfänger versendet.', - enabled: true, - schedule: 'dienstags 08:00', - requiresApproval: true, - dependsOnChannel: AgentChannelType.EMAIL, - }, - { - id: 'nora-task-07', - title: 'Umzugs- und Expansionssignale als Leads ausweisen', - description: - 'Kennzeichnet Signale zu Umzug und Expansion als Lead, ergänzt Firma, Region, gesuchtes Segment und Zeithorizont und bereitet die Übergabe an das simulierte CRM vor. Übergeben wird erst nach ausdrücklicher Freigabe.', - enabled: true, - schedule: 'bei Erkennung eines Umzugs- oder Expansionssignals', - requiresApproval: true, - dependsOnSystem: AgentSystemType.CRM, - }, - { - id: 'nora-task-08', - title: 'monatliche Markteinschätzung erstellen', - description: - 'Fasst die Signale des Monats je Region und Segment zu einer Einschätzung zusammen: Bewegung im Markt, auffällige Häufungen, offene Reviews und Vergleich zum Vormonat. Der Bericht wird als Vorschlag vorgelegt und nach Freigabe verteilt.', - enabled: true, - schedule: 'monatlich am 1. um 07:00', - requiresApproval: true, - dependsOnChannel: AgentChannelType.EMAIL, - }, ], channels: [ @@ -193,46 +160,31 @@ export const noraAgent: TeamAgent = { }, ], + /* + * Runde 10, §3.12: Öffentliche Webquellen, Zefix und SHAB sind zu Livia + * gewandert — sie beobachtet den Markt. Nora braucht den Bestand, und dafür + * genau einen lesenden Zugang. + */ systems: [ { - id: 'nora-sys-public-web', - type: AgentSystemType.PUBLIC_WEB, + id: 'nora-sys-erp', + type: AgentSystemType.IMMOTOP2, access: AgentAccessLevel.READ, status: AgentConnectionStatus.CONNECTED, + title: 'ERP', usage: - 'Öffentlich zugängliche Firmenwebseiten, Standortmeldungen und Medienberichte aus Zürich, Winterthur, Zug, Basel, Bern und St. Gallen als Ausgangspunkt für Marktsignale.', + 'Lesender Zugriff auf die interne Objektdatenbasis für den Abgleich von Nachfrageprofilen mit verfügbaren, zukünftig verfügbaren und optional belegten Objekten.', permissionNote: - 'Nur Leserecht auf frei zugängliche Seiten. Nora meldet sich nirgends an, füllt keine Formulare aus und veröffentlicht nichts.', - }, - { - id: 'nora-sys-shab', - type: AgentSystemType.SHAB, - access: AgentAccessLevel.READ, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Amtliche Meldungen zu Gründungen, Sitzverlegungen, Umfirmierungen und Liquidationen — die verlässlichste Grundlage für Umzugs- und Neugründungssignale.', - permissionNote: - 'Nur Leserecht. Nora liest veröffentlichte Meldungen und übernimmt Meldungsnummer und Datum als Quellenangabe.', - }, - { - id: 'nora-sys-zefix', - type: AgentSystemType.ZEFIX, - access: AgentAccessLevel.READ, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Abgleich von Firmenname, Rechtsform und eingetragenem Sitz, damit ein Signal der richtigen Firma und der richtigen Region zugeordnet wird.', - permissionNote: - 'Nur Leserecht auf den öffentlichen Handelsregisterindex. Es werden keine Einträge erfasst oder verändert.', + 'Nur lesend. Nora verändert keine Objekt- oder Mietdaten; das Matching-Ergebnis ist eine Auswertung, kein Eintrag.', }, { id: 'nora-sys-crm', type: AgentSystemType.CRM, - access: AgentAccessLevel.WRITE, + access: AgentAccessLevel.READ, status: AgentConnectionStatus.CONNECTED, usage: - 'Ablage der freigegebenen Leads aus Umzugs- und Expansionssignalen, damit die Akquisition direkt weiterarbeiten kann.', - permissionNote: - 'Übergibt Leads an ein simuliertes CRM. Geschrieben wird erst nach Freigabe durch die Marktbeobachtung; bestehende Datensätze werden weder überschrieben noch gelöscht.', + 'Bestehende Interessenten und laufende Vorgänge, um eine bereits bekannte Nachfrage nicht doppelt als neuen Lead zu führen.', + permissionNote: 'Nur lesend.', }, { id: 'nora-sys-workspace', @@ -240,148 +192,88 @@ export const noraAgent: TeamAgent = { access: AgentAccessLevel.READ_WRITE, status: AgentConnectionStatus.CONNECTED, usage: - 'Führt die Signalliste, die Review-Queue und die Entwürfe für Wochen-Mail und Markteinschätzung; liest Objekt- und Portfoliostammdaten für die Zuordnung.', + 'Matching-Ergebnisse und Laufprotokolle im Arbeitsbereich der Vermarktung.', permissionNote: - 'Lese- und Schreibrecht im eigenen Arbeitsbereich. Nora legt dort Signale, Reviews und Entwürfe ab — der Versand nach aussen bleibt an die Freigabe gebunden.', + 'Schreibt nur in den eigenen Bereich «Matching»; Objektdossiers bleiben unberührt.', }, ], + /* + * Runde 10, §3.14: Die Einstellungen beschreiben jetzt das Matching und + * sonst nichts. + * + * Weggefallen sind «Regionen», «Segmente», «Signalarten», «Mindestkonfidenz» + * und «Review-Schwelle» — sie steuerten die Marktbeobachtung, und die macht + * seit Runde 8 Livia. Ebenso die Lead-Weitergabe ans CRM und der + * Wochenversand: Nora übergibt keine Leads mehr, sie gleicht sie ab. + */ settings: [ { - id: 'nora-set-regions', - label: 'Regionen', - description: 'Nur Meldungen aus diesen Regionen werden als Marktsignal erfasst.', - kind: AgentSettingKind.MULTI_SELECT, - group: AgentSettingGroup.GENERAL, - value: ['ZUERICH', 'WINTERTHUR', 'ZUG', 'BASEL', 'BERN', 'ST_GALLEN'], - options: [ - { value: 'ZUERICH', label: 'Zürich' }, - { value: 'WINTERTHUR', label: 'Winterthur' }, - { value: 'ZUG', label: 'Zug' }, - { value: 'BASEL', label: 'Basel' }, - { value: 'BERN', label: 'Bern' }, - { value: 'ST_GALLEN', label: 'St. Gallen' }, - { value: 'LUZERN', label: 'Luzern' }, - ], - }, - { - id: 'nora-set-segments', - label: 'Segmente', + id: 'nora-set-include-occupied', + label: 'Belegte Objekte standardmässig einbeziehen', description: - 'Nutzungsarten der kommerziell genutzten Immobilien, die Nora beobachtet.', - kind: AgentSettingKind.MULTI_SELECT, - group: AgentSettingGroup.GENERAL, - value: ['BUERO', 'RETAIL', 'GASTRONOMIE', 'LOGISTIK'], - options: [ - { value: 'BUERO', label: 'Büro' }, - { value: 'RETAIL', label: 'Retail' }, - { value: 'GASTRONOMIE', label: 'Gastronomie' }, - { value: 'LOGISTIK', label: 'Logistik' }, - { value: 'GEWERBE', label: 'Gewerbe' }, - { value: 'PRODUKTION', label: 'Produktion' }, - ], - }, - { - id: 'nora-set-signal-types', - label: 'Signalarten', - description: 'Diese Arten von Marktbewegungen werden erfasst und eingeordnet.', - kind: AgentSettingKind.MULTI_SELECT, - group: AgentSettingGroup.SPECIFIC, - value: ['UMZUG', 'EXPANSION', 'NEUGRUENDUNG', 'FILIALSCHLIESSUNG'], - options: [ - { value: 'UMZUG', label: 'Umzug' }, - { value: 'EXPANSION', label: 'Expansion' }, - { value: 'VERKLEINERUNG', label: 'Verkleinerung' }, - { value: 'NEUGRUENDUNG', label: 'Neugründung' }, - { value: 'FILIALSCHLIESSUNG', label: 'Filialschliessung' }, - ], - }, - { - id: 'nora-set-min-confidence', - label: 'Mindestkonfidenz', - description: - 'Ab diesem Wert gilt ein Signal als verifiziert und darf in Wochen-Mail und Lead-Liste aufgenommen werden.', - kind: AgentSettingKind.NUMBER, - group: AgentSettingGroup.SPECIFIC, - value: 70, - unit: '%', - min: 40, - max: 95, - }, - { - id: 'nora-set-review-threshold', - label: 'Review-Schwelle', - description: - 'Signale unterhalb dieses Werts gehen in die Review-Queue und warten auf Sichtung durch die Marktbeobachtung.', - kind: AgentSettingKind.NUMBER, - group: AgentSettingGroup.APPROVAL, - value: 55, - unit: '%', - min: 20, - max: 90, - }, - { - id: 'nora-set-lead-handover', - label: 'Lead-Weitergabe aktiv', - description: - 'Legt fest, ob freigegebene Umzugs- und Expansionssignale als Lead an das simulierte CRM übergeben werden.', + 'Bezieht vermietete Objekte von Anfang an in den Kandidatenkreis ein. Vorgabe ist aus: Ein belegtes Objekt ist kein Angebot, sondern die Frage, ob für eine vermietete Fläche konkurrierende Nachfrage besteht.', kind: AgentSettingKind.BOOLEAN, - group: AgentSettingGroup.APPROVAL, - value: true, + group: AgentSettingGroup.SPECIFIC, + value: false, }, { - id: 'nora-set-weekly-recipients', - label: 'Empfänger der Wochen-Mail', + id: 'nora-set-min-match', + label: 'Mindest-Match für eine Empfehlung', description: - 'Adressen, die die freigegebene Wochen-Mail erhalten — mehrere Adressen mit Komma trennen.', - kind: AgentSettingKind.TEXT, - group: AgentSettingGroup.DELIVERY, - value: - 'marktbeobachtung@property-on.ch, akquisition.zuerich@property-on.ch, bewirtschaftung.winterthur@property-on.ch', + 'Ab diesem Wert gilt ein Objekt als Empfehlung. Objekte darunter bleiben in der Liste sichtbar — sie werden nicht als Treffer hervorgehoben.', + kind: AgentSettingKind.NUMBER, + group: AgentSettingGroup.SPECIFIC, + value: 60, + unit: '%', + min: 0, + max: 100, + infoText: + 'Der Match-Wert zeigt, wie gut das von Livia erstellte Nachfrageprofil mit den verfügbaren Informationen eines Objekts übereinstimmt. ' + + 'Aus dem Suchprofil werden die Anforderungen gelesen, die darin tatsächlich vorkommen — Standort, Fläche, Nutzungsart und, wo das Profil sie nennt, ' + + 'Erreichbarkeit, Parkierung, Ausbaustand und Sichtbarkeit. Dazu kommt immer die Verfügbarkeit. ' + + 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, geht es gar nicht in die Rechnung ein — ' + + 'das Objekt verliert dadurch keine Punkte, gewinnt aber auch keine. ' + + 'Belegte Objekte sind ausgeschlossen, solange sie nicht ausdrücklich einbezogen werden. ' + + 'Es gibt keine feste Prozentgewichtung je Kriterium, weil je Objekt unterschiedlich viel prüfbar ist.', }, { - id: 'nora-set-send-time', - label: 'Versandzeit', - description: 'Uhrzeit des wöchentlichen Versands, jeweils dienstags.', - kind: AgentSettingKind.TIME, - group: AgentSettingGroup.DELIVERY, - value: '08:00', + id: 'nora-set-max-results', + label: 'Höchstzahl angezeigter Objekte', + description: + 'Begrenzt die Trefferliste je Lauf. Abgeglichen wird immer der ganze Kandidatenkreis — die Grenze betrifft nur die Anzeige.', + kind: AgentSettingKind.NUMBER, + group: AgentSettingGroup.SPECIFIC, + value: 25, + min: 5, + max: 200, }, ], + /* + * Runde 10, §3.11: Zwei Kennzahlen, beide aus echten Aktionen. + * + * Die Werte hier sind der Ausgangsstand vor dem ersten Lauf — beide null, + * weil noch nichts passiert ist. Die Anzeige ersetzt sie zur Laufzeit durch + * die tatsächlichen Zahlen aus den Matching-Läufen und den zugeordneten + * Objekten. Eine erfundene Startzahl wäre genau der Fantasiewert, den diese + * Kennzahlen nicht zeigen dürfen. + */ metrics: [ { - id: 'nora-metric-weekly-signals', - label: 'Signale der Woche', - value: '128', - hint: 'Erfasste Meldungen aus Quellen und Feeds vom 14.05.2026 bis 20.05.2026', + id: 'nora-metric-match-runs', + label: 'Anzahl durchgeführte Matchings', + value: '0', + hint: 'Zählt tatsächlich ausgeführte Matching-Läufe — weder Seitenaufrufe noch die Zahl der Leads.', }, { - id: 'nora-metric-verified-signals', - label: 'Verifizierte Signale', - value: '86', - hint: 'Konfidenz von mindestens 70 % und mit einer zweiten Quelle bestätigt', - }, - { - id: 'nora-metric-open-reviews', - label: 'Offene Reviews', - value: '19', - hint: 'Signale in der Review-Queue, ältester Eintrag seit 3 Tagen offen', - }, - { - id: 'nora-metric-generated-leads', - label: 'Erzeugte Leads', - value: '23', - hint: 'Freigegebene Umzugs- und Expansionssignale, letzte Übergabe am 19.05.2026', - }, - { - id: 'nora-metric-monitored-regions', - label: 'Überwachte Regionen', - value: '6', - hint: 'Zürich, Winterthur, Zug, Basel, Bern und St. Gallen', + id: 'nora-metric-affected-objects', + label: 'Anzahl betroffene Objekte', + value: '0', + hint: 'Eindeutig intern zugeordnete Objekte, die von einem Risikosignal betroffen sind.', }, ], - headlineMetricId: 'nora-metric-weekly-signals', + headlineMetricId: 'nora-metric-match-runs', lastRun: '2026-05-20T06:00:00.000Z', } diff --git a/src/pages/supply/MarketIntelligence.tsx b/src/pages/supply/MarketIntelligence.tsx index 8310dde..6807000 100644 --- a/src/pages/supply/MarketIntelligence.tsx +++ b/src/pages/supply/MarketIntelligence.tsx @@ -1,10 +1,13 @@ import { useCallback, useMemo, useState } from 'react' -import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material' +import { useSearchParams } from 'react-router' +import { Box, Button, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material' +import { ArrowLeft } from 'lucide-react' import { AgentWorkspaceHero } from '../../components/team' import { CrmLeadDetail, LeadChannelFilterBar, LeadDetail, + PropertyMatchSection, UnifiedLeadList, } from '../../components/market-leads' import { HinweisDetail, HinweisErfassenDialog } from '../../components/markt-hinweise' @@ -47,6 +50,31 @@ export default function MarketIntelligence() { const { data: leads, isLoading } = useUnifiedLeads() + /* + * Übergabe aus Livia (Runde 10, §§2.14/3.3). + * + * `lead` benennt denselben Eintrag aus demselben Bestand — es entsteht keine + * Kopie. `matching=1` sagt, dass der Abgleich sofort laufen darf; ohne das + * Kennzeichen passiert beim Öffnen nichts von selbst. + * + * Beides steht in der Adresse und nicht im Navigationszustand, damit die + * Übergabe ein Neuladen überlebt und der Link weitergegeben werden kann. + */ + const [params, setParams] = useSearchParams() + const uebergebeneId = params.get('lead') + const autoMatching = params.get('matching') === '1' + + /** Ein KI-Lead, der auf ganzer Seite gezeigt wird — Schublade nur für die anderen Kanäle. */ + const seitenLead = useMemo(() => { + if (!uebergebeneId) return null + const treffer = leads.find(l => l.id === uebergebeneId) + return treffer?.channel === LeadChannel.KI_SIGNAL ? treffer : null + }, [uebergebeneId, leads]) + + const zurueckZurListe = useCallback(() => { + setParams({}, { replace: true }) + }, [setParams]) + // Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4). const { visible, counts } = useMemo(() => { const tally: Record = { @@ -65,6 +93,27 @@ export default function MarketIntelligence() { const resetFilter = useCallback(() => setChannel(LEAD_CHANNEL_ALL), []) const closeDetail = useCallback(() => setSelected(null), []) + /** + * KI-Leads öffnen auf ganzer Seite, die übrigen Kanäle in der Schublade. + * + * Der Unterschied ist nicht Geschmack: Nur beim KI-Lead hängt das + * Property-Matching daran, und das braucht die Breite. Ein Netzwerk-Hinweis + * ist in zehn Zeilen erzählt. + * + * Ausdrücklich **ohne** `matching=1` — ein normal geöffneter Lead löst + * keinen Lauf aus (§3.3). + */ + const handleSelect = useCallback( + (lead: UnifiedLead) => { + if (lead.channel === LeadChannel.KI_SIGNAL) { + setParams({ lead: lead.id }, { replace: false }) + return + } + setSelected(lead) + }, + [setParams], + ) + // Der neue Hinweis soll sofort auffindbar sein — auch wenn gerade nach einem // anderen Kanal gefiltert wird. const handleCreated = useCallback(() => { @@ -79,6 +128,32 @@ export default function MarketIntelligence() { Er scrollt jetzt mit und wechselt dabei in den sticky Zustand (§11). */} + {/* + Detailseite statt Schublade (Runde 10, §3.2). + Sie tritt an die Stelle der Liste, statt daneben zu stehen: Das + Property-Matching braucht die volle Breite, und zwei Bereiche + nebeneinander hätten beide zu wenig. + */} + {seitenLead ? ( + + + + } + /> + + ) : ( + <> + + )} {/* Detailnavigation: pro Kanal die passende Ansicht, in einer Schublade. */} - {selected?.channel === LeadChannel.KI_SIGNAL && ( - - )} + {/* KI-Leads öffnen seit Runde 10 auf ganzer Seite — hier bleiben die + beiden Kanäle, die ohne Matching auskommen (§3.2). */} {selected?.channel === LeadChannel.NETZWERK && } {selected?.channel === LeadChannel.CRM && } diff --git a/src/provider/IMatchRunProvider.ts b/src/provider/IMatchRunProvider.ts new file mode 100644 index 0000000..9df683a --- /dev/null +++ b/src/provider/IMatchRunProvider.ts @@ -0,0 +1,7 @@ +import type { MatchRun } from '../domain/matchRun' + +/** Ablage der durchgeführten Matching-Läufe (Runde 10, §§3.11/3.13). */ +export interface IMatchRunProvider { + getAll(): Promise + add(run: MatchRun): Promise +} diff --git a/src/provider/MockupMatchRunProvider.ts b/src/provider/MockupMatchRunProvider.ts new file mode 100644 index 0000000..f8bbd82 --- /dev/null +++ b/src/provider/MockupMatchRunProvider.ts @@ -0,0 +1,26 @@ +/** + * Property On — die Matching-Läufe überleben einen Reload (Runde 10, §3.11). + * + * Kein Seed: Ein vorab erfundener Lauf wäre genau der Fantasiewert, den die + * Kennzahl nicht zeigen darf. Die Liste beginnt leer und füllt sich nur durch + * tatsächliche Läufe. + */ + +import type { IMatchRunProvider } from './IMatchRunProvider' +import type { MatchRun } from '../domain/matchRun' +import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence' + +let store: MatchRun[] = loadVersioned(TEAM_STORAGE_KEYS.MATCH_RUNS) ?? [] + +export const MockupMatchRunProvider: IMatchRunProvider = { + async getAll() { + return [...store] + }, + + async add(run) { + // Neuestes zuerst — das Protokoll liest man von oben. + store = [run, ...store] + persistVersioned(TEAM_STORAGE_KEYS.MATCH_RUNS, store) + return run + }, +} diff --git a/src/provider/teamPersistence.ts b/src/provider/teamPersistence.ts index 76cb72e..a932d5b 100644 --- a/src/provider/teamPersistence.ts +++ b/src/provider/teamPersistence.ts @@ -19,6 +19,8 @@ export const TEAM_STORAGE_KEYS = { CONNECTIONS: `${PREFIX}connections`, /** Von einem Recherchelauf erzeugte Leads (Runde 10, §2.12). */ RESEARCH_LEADS: `${PREFIX}research-leads`, + /** Durchgeführte Matching-Läufe (Runde 10, §3.11). */ + MATCH_RUNS: `${PREFIX}match-runs`, /** Manuell abgelegte Newsquellen als Dokumente (Runde 10, §2.6). */ RESEARCH_DOCUMENTS: `${PREFIX}research-documents`, } as const diff --git a/src/services/matchRunService.ts b/src/services/matchRunService.ts new file mode 100644 index 0000000..e7bcb4f --- /dev/null +++ b/src/services/matchRunService.ts @@ -0,0 +1,73 @@ +/** + * Property On — Matching ausführen und festhalten (Runde 10, §§3.7/3.13). + * + * Der Dienst tut beides in einem Schritt: rechnen und protokollieren. Das ist + * Absicht — ein Lauf ohne Eintrag wäre eine Zahl ohne Beleg, und ein Eintrag + * ohne Lauf wäre ein Beleg ohne Zahl. Wer das Matching auslöst, bekommt + * zwangsläufig beides. + */ + +import { MockupMatchRunProvider } from '../provider/MockupMatchRunProvider' +import { agentProtocolService } from './agentProtocolService' +import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol' +import { fuehreMatchingAus } from '../features/matching/leadPropertyMatcher' +import type { MatchLaufErgebnis, MatchLaufOptionen } from '../features/matching/leadPropertyMatcher' +import type { MatchRun } from '../domain/matchRun' +import type { FutureSignal } from '../domain/futureSignal' +import type { Property } from '../domain/property' +import type { ListResponse, ItemResponse } from './types' +import { throwServiceError } from './errors' + +const provider = MockupMatchRunProvider + +export interface RunMatchingInput { + signal: FutureSignal + properties: Property[] + options: MatchLaufOptionen + trigger: MatchRun['trigger'] +} + +export const matchRunService = { + async getAll(): Promise> { + try { + const data = await provider.getAll() + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + } catch (err) { + throwServiceError(err) + } + }, + + async run(input: RunMatchingInput): Promise> { + try { + const ergebnis = fuehreMatchingAus(input.signal, input.properties, input.options) + const jetzt = new Date().toISOString() + + const run: MatchRun = { + id: `match-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + leadId: input.signal.id, + leadTitle: input.signal.companyName ?? input.signal.title ?? input.signal.locationHint, + timestamp: jetzt, + candidateCount: ergebnis.candidateCount, + includedOccupied: input.options.includeOccupied, + topScore: ergebnis.matches[0]?.score ?? 0, + trigger: input.trigger, + } + await provider.add(run) + + await agentProtocolService.logUserAction({ + agentId: 'nora', + eventType: AgentProtocolEventType.TASK_RUN, + title: 'Matching ausgeführt', + description: + `Lead «${run.leadTitle}» gegen ${run.candidateCount} ${run.candidateCount === 1 ? 'Objekt' : 'Objekte'} abgeglichen` + + `${run.includedOccupied ? ', belegte eingeschlossen' : ''}` + + ` · bester Wert ${run.topScore} %`, + status: AgentProtocolStatus.SUCCESS, + }) + + return { data: { ergebnis, run } } + } catch (err) { + throwServiceError(err) + } + }, +}