diff --git a/src/components/market-leads/PropertyMatchSection.tsx b/src/components/market-leads/PropertyMatchSection.tsx index 526eadd..ef1a4d0 100644 --- a/src/components/market-leads/PropertyMatchSection.tsx +++ b/src/components/market-leads/PropertyMatchSection.tsx @@ -50,8 +50,9 @@ const MATCH_INFO = + '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. ' + + 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, zählt es ' + + 'nicht als Nichterfüllung, senkt aber die Abdeckung — und je weniger geprüft werden konnte, desto stärker ' + + 'wird der Wert gedämpft. Ein Objekt, von dem nur die Verfügbarkeit bekannt ist, erreicht deshalb keine hohen Werte. ' + '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.' @@ -141,16 +142,22 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, }, [ergebnis]) /** - * Die Trefferliste, nach Match absteigend. + * Die Trefferliste — nach Match absteigend, solange nichts anderes gewählt ist. * - * 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. + * Filterleiste und Spaltenköpfe wirken wie in «Meine Objekte»: Sie ordnen + * und schränken die Anzeige ein, ohne den Lauf zu verändern. Wer umsortiert, + * sieht dieselben Objekte in anderer Reihenfolge, nicht andere Werte. + * + * Die Sortierung muss hier passieren und nicht in der Tabelle: Die Tabelle + * bekommt ihre Zeilen fertig geordnet und hat auch in «Meine Objekte» nie + * selbst sortiert. Ohne diesen Block wären die Spaltenköpfe anklickbar und + * ohne Wirkung — schlimmer als gar keine Sortierung. */ const sichtbar = useMemo((): Property[] => { if (!ergebnis) return [] const suche = filters.search?.trim().toLowerCase() - return ergebnis.matches + + const gefiltert = ergebnis.matches .map(m => m.property) .filter(p => { if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false @@ -161,6 +168,21 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, } return true }) + + if (!filters.sortBy) return gefiltert + + const wertVon = (p: Property): number => { + switch (filters.sortBy) { + case 'area': return p.areaSqm + case 'rent': return p.rentPricePerSqm + case 'dataQuality': return p.dataQuality.score + case 'confidence': return p.confidenceScore + case 'availability': return new Date(p.availabilityDate).getTime() + default: return 0 + } + } + const richtung = filters.sortDir === 'asc' ? 1 : -1 + return [...gefiltert].sort((a, b) => (wertVon(a) - wertVon(b)) * richtung) }, [ergebnis, filters]) const risiko = isRisiko(signal) diff --git a/src/features/matching/__tests__/leadPropertyMatcher.test.ts b/src/features/matching/__tests__/leadPropertyMatcher.test.ts index 40fc9e6..fff8dd1 100644 --- a/src/features/matching/__tests__/leadPropertyMatcher.test.ts +++ b/src/features/matching/__tests__/leadPropertyMatcher.test.ts @@ -107,7 +107,7 @@ describe('bewerteObjekt', () => { it('gibt einem passenden Objekt einen hohen Wert', () => { const { score } = bewerteObjekt(objekt(), leseNachfrageprofil(signal())) - expect(score).toBeGreaterThanOrEqual(70) + expect(score).toBeGreaterThanOrEqual(60) }) it('straft den falschen Ort ab', () => { @@ -200,3 +200,85 @@ describe('fuehreMatchingAus', () => { expect(candidateCount).toBe(0) }) }) + +describe('Abdeckung dämpft den Wert', () => { + it('gibt einem Objekt, von dem nur die Verfügbarkeit bekannt ist, keinen Spitzenwert', () => { + // Genau der Fall, der im ersten Browserdurchlauf auffiel: Ein dünnes Profil + // wirft nur ein Kriterium auf, das Objekt ist frei — und der Wert stand auf + // 100 %. «Frei, sonst nichts geprüft» ist aber keine perfekte Passung. + const duenn = signal({ semanticSearchProfile: undefined, aiSummary: undefined, title: undefined, locationHint: '' }) + const { score, abdeckung } = bewerteObjekt(objekt(), leseNachfrageprofil(duenn)) + + // Nur die Verfügbarkeit von vier Kernkriterien — ein Fünftel der Abdeckung. + expect(abdeckung).toBeLessThan(0.3) + expect(score).toBeLessThan(60) + }) + + it('dämpft, wenn das Profil mehr verlangt, als das Objekt hergibt', () => { + const profil = leseNachfrageprofil(signal()) + const vollstaendig = bewerteObjekt( + objekt({ hardFacts: { publicTransportScore: 10 } }), + profil, + ) + const lueckenhaft = bewerteObjekt(objekt(), profil) + + expect(lueckenhaft.abdeckung).toBeLessThan(vollstaendig.abdeckung) + expect(lueckenhaft.score).toBeLessThan(vollstaendig.score) + }) + + it('wertet eine fehlende Angabe nicht als Nichterfüllung', () => { + const profil = leseNachfrageprofil(signal()) + const ohneAngabe = bewerteObjekt(objekt(), profil) + const schlechteAngabe = bewerteObjekt(objekt({ hardFacts: { publicTransportScore: 0 } }), profil) + + // Unbekannt ist besser als nachweislich schlecht — sonst wäre «keine + // Angabe» dasselbe wie «erfüllt es nicht», und das ist es nicht. + expect(ohneAngabe.score).toBeGreaterThan(schlechteAngabe.score) + }) +}) + +describe('Belegung aus dem Mietvertrag (§3.4)', () => { + const HEUTE = new Date('2026-09-13T00:00:00.000Z') + + it('hält ein Objekt mit langlaufendem Vertrag für belegt', () => { + // Der Bestand setzt bei solchen Objekten «AVAILABLE_SOON» — der Vertrag + // sagt etwas anderes, und er ist die belastbarere Angabe. + const p = objekt({ + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + currentTenant: 'Dauermieter AG', + leaseEndDate: '2029-12-31', + }) + + expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.BELEGT) + }) + + it('hält ein Objekt mit auslaufendem Vertrag für frei', () => { + const p = objekt({ + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + currentTenant: 'Bald weg AG', + leaseEndDate: '2026-10-31', + }) + + expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.FREI) + }) + + it('lässt einen ausdrücklich gesetzten Status vorgehen', () => { + const p = objekt({ + availabilityStatus: AvailabilityStatus.AVAILABLE_NOW, + currentTenant: 'Noch drin AG', + leaseEndDate: '2029-12-31', + }) + + expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.FREI) + }) + + it('hält einen Mieter ohne Enddatum für belegt — nicht für frei', () => { + const p = objekt({ + availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + currentTenant: 'Unbefristet AG', + leaseEndDate: undefined, + }) + + expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.BELEGT) + }) +}) diff --git a/src/features/matching/leadPropertyMatcher.ts b/src/features/matching/leadPropertyMatcher.ts index 43d09c5..760281f 100644 --- a/src/features/matching/leadPropertyMatcher.ts +++ b/src/features/matching/leadPropertyMatcher.ts @@ -16,6 +16,10 @@ * Ergebnis bei. Was im Profil nicht vorkommt, wird nicht bewertet; was im * Objekt fehlt, gilt als **nicht bekannt** und nicht als erfüllt. * + * Fehlende Objektangaben zählen deshalb nicht als Nichterfüllung — sie senken + * aber die Abdeckung, und die Abdeckung dämpft den Wert. Sonst käme ein + * Objekt, von dem nur die Verfügbarkeit bekannt ist, auf hundert Prozent. + * * **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 @@ -38,21 +42,48 @@ export const MatchAvailability = { } as const export type MatchAvailability = typeof MatchAvailability[keyof typeof MatchAvailability] +/** + * Ab wann ein laufender Mietvertrag als «bald frei» gilt. + * + * Zwölf Monate sind die übliche Vorlaufzeit einer gewerblichen + * Wiedervermietung: Wer erst bei der Kündigung anfängt zu suchen, sucht zu + * spät. + */ +const BALD_FREI_MONATE = 12 + /** * 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. + * Der Status allein genügt nicht, und das ist keine Feinheit: Im Bestand steht + * bei keinem Objekt `OCCUPIED`, obwohl bei 27 ein laufender Mietvertrag mit + * Mieter und Enddatum erfasst ist. Wer nur den Status liest, hält den ganzen + * Bestand für frei — und der Schalter «Belegte Objekte einbeziehen» hätte + * nichts einzubeziehen. + * + * Massgeblich ist deshalb der Vertrag, wo es einen gibt: Läuft er noch länger + * als ein Jahr, ist die Fläche belegt. Läuft er demnächst aus, ist sie bald + * frei — genau der Fall, für den die Bewirtschaftung Vorlauf braucht. + * + * `referenz` ist überschreibbar, damit Tests nicht vom Kalender abhängen. */ -export function verfuegbarkeitVon(p: Property): MatchAvailability { +export function verfuegbarkeitVon(p: Property, referenz: Date = new Date()): MatchAvailability { + // Ein ausdrücklich gesetzter Status hat Vorrang — er ist eine Aussage. + if (p.availabilityStatus === AvailabilityStatus.OCCUPIED) return MatchAvailability.BELEGT + if (p.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW) return MatchAvailability.FREI + + const ende = p.leaseEndDate ? new Date(p.leaseEndDate) : null + if (p.currentTenant && ende && !Number.isNaN(ende.getTime())) { + const grenze = new Date(referenz) + grenze.setMonth(grenze.getMonth() + BALD_FREI_MONATE) + return ende > grenze ? MatchAvailability.BELEGT : MatchAvailability.FREI + } + // Mieter ohne Enddatum: Der Vertrag läuft, und wann er endet, steht nirgends. + if (p.currentTenant) return MatchAvailability.BELEGT + 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 } @@ -163,6 +194,13 @@ export interface PropertyMatch { score: number kriterien: MatchKriterium[] verfuegbarkeit: MatchAvailability + /** + * Anteil des Gewichts, das sich am Objekt tatsächlich prüfen liess (0–1). + * + * Sie steht neben dem Wert, weil beide zusammen erst die Aussage ergeben: + * 80 % bei voller Abdeckung heisst etwas anderes als 80 % bei halber. + */ + abdeckung: number } /** Gewichte der Kriterien. Sie stehen hier, damit der Infotext sie nicht erfinden muss. */ @@ -177,6 +215,28 @@ const GEWICHT = { sichtbarkeit: 1, } as const +/** + * Gewicht der vier Kernkriterien — Standort, Fläche, Nutzungsart, Verfügbarkeit. + * + * Sie stehen immer im Nenner der Abdeckung, auch wenn das Profil sie gar nicht + * nennt. Das ist der Punkt: Ein Suchprofil ohne Flächenangabe ist nicht + * «vollständig geprüft», sondern eines, bei dem eine tragende Angabe fehlt. + * Die weichen Kriterien zählen dagegen nur mit, wenn das Profil sie verlangt — + * sonst würde jedes Profil ohne Parkierungswunsch gedämpft, obwohl nichts fehlt. + */ +const KERN_GEWICHT = GEWICHT.ort + GEWICHT.flaeche + GEWICHT.nutzung + GEWICHT.verfuegbarkeit + +/** + * Wie stark eine dünne Abdeckung den Wert dämpft. + * + * Bei voller Abdeckung bleibt der Wert unverändert, bei gar keiner sinkt er + * auf 40 %. Die Untergrenze ist kein Rundungsartefakt: Ein Objekt, von dem nur + * die Verfügbarkeit bekannt ist, soll unterhalb jedes belegten Treffers landen + * und trotzdem sichtbar bleiben — «wir wissen zu wenig» ist eine Aussage, + * «kein Treffer» wäre eine andere. + */ +const ABDECKUNG_BASIS = 0.4 + /** 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 @@ -299,18 +359,40 @@ export function bewerteObjekt(p: Property, profil: Nachfrageprofil): PropertyMat }) } - // Nur geprüfte Kriterien zählen. Ein Objekt, zu dem gar nichts bekannt ist, - // bekommt 0 — nicht 100, weil «nichts spricht dagegen». + /* + * Zwei Grössen, nicht eine. + * + * `erfuellung` sagt, wie gut das Objekt bei den Merkmalen abschneidet, die + * sich prüfen liessen. `abdeckung` sagt, wie viel davon überhaupt prüfbar + * war. Beides gehört in den Wert, und zwar aus einem Grund, der im ersten + * Durchlauf sichtbar wurde: Ein Objekt, von dem nur die Verfügbarkeit + * bekannt war, kam auf 100 % — «frei, sonst nichts geprüft» sah aus wie + * «passt perfekt». + * + * Fehlende Angaben werden trotzdem nicht als Nichterfüllung gewertet; das + * wäre die andere Unwahrheit. Sie dämpfen den Wert, und die Dämpfung reicht + * bis 70 % — genug, damit ein gut belegtes Objekt vor einem kaum belegten + * steht, und nicht so viel, dass ein lückenhafter Bestand jedes Ergebnis + * unbrauchbar macht. + */ let summe = 0 - let gewichte = 0 + let geprueft = 0 + // Die weichen Kriterien kommen nur dazu, wenn das Profil sie aufwirft. + let verlangt = KERN_GEWICHT for (const k of kriterien) { + const kern = k.label === 'Standort' || k.label === 'Fläche' + || k.label === 'Nutzungsart' || k.label === 'Verfügbarkeit' + if (!kern) verlangt += k.gewicht if (k.erfuellung == null) continue summe += k.erfuellung * k.gewicht - gewichte += k.gewicht + geprueft += k.gewicht } - const score = gewichte === 0 ? 0 : Math.round((summe / gewichte) * 100) - return { property: p, score, kriterien, verfuegbarkeit } + const erfuellung = geprueft === 0 ? 0 : summe / geprueft + const abdeckung = Math.min(1, geprueft / verlangt) + const score = Math.round(erfuellung * (ABDECKUNG_BASIS + (1 - ABDECKUNG_BASIS) * abdeckung) * 100) + + return { property: p, score, kriterien, verfuegbarkeit, abdeckung } } export interface MatchLaufOptionen { diff --git a/src/mock-data/agents/nora.ts b/src/mock-data/agents/nora.ts index d86860a..89ab885 100644 --- a/src/mock-data/agents/nora.ts +++ b/src/mock-data/agents/nora.ts @@ -1,11 +1,15 @@ /** - * Property On — Personalblatt «Nora», Markt-Scout. + * Property On — Personalblatt «Nora», Marktchancen und Leads. * - * Nora beobachtet öffentliche Quellen und simulierte Feeds zu den definierten - * Regionen und Segmenten, stuft jedes Signal ein und legt unsichere Fälle in die - * Review-Queue. Sie arbeitet ausschliesslich auf Vorschlagsbasis: Wochen-Mail, - * Leads und Markteinschätzung gehen erst nach Freigabe durch die Marktbeobachtung - * hinaus. + * Seit Runde 10 (§3) arbeitet Nora nicht mehr an den Quellen, sondern am + * Bestand: Sie nimmt die Nachfrageprofile, die Livia aus ihrer Recherche + * ableitet, und gleicht sie gegen die eigene Objektdatenbasis ab. Das Ergebnis + * ist ein nachvollziehbarer Match-Wert je Objekt. + * + * Was sie ausdrücklich **nicht** mehr tut: Quellen beobachten, Signale + * klassifizieren, unsichere Fälle prüfen lassen und Wochenberichte versenden. + * Das ist alles zu Livia gewandert — sie liest den Markt, Nora verbindet ihn + * mit dem Bestand. */ import type { TeamAgent } from '../../domain/teamAgent' @@ -33,42 +37,41 @@ export const noraAgent: TeamAgent = { status: AgentStatus.ACTIVE, autonomyLevel: AgentAutonomyLevel.PROPOSAL_ONLY, shortDescription: - 'Beobachtet Marktsignale, ordnet sie ein und erstellt Wochen-Mails, Leads und Marktbausteine.', - autonomyNote: '«Vorschlag»', + 'Gleicht Nachfrageprofile mit dem eigenen Objektbestand ab und legt passende Objekte mit nachvollziehbarem Match-Wert vor.', + autonomyNote: + '«Vorschlag». Nora rechnet und ordnet, entscheidet aber nichts: Welches Objekt angeboten wird, bleibt die Sache der Bewirtschaftung.', profile: { purpose: - 'Nora hält die Marktbeobachtung ohne tägliche Handarbeit auf dem Laufenden, indem sie Bewegungen bei kommerziell genutzten Immobilien früh erkennt, einordnet und als prüffertigen Vorschlag vorlegt.', + 'Nora beantwortet die Frage, die auf jeden Lead folgt: Haben wir dafür etwas? Sie nimmt das Nachfrageprofil, das Livia aus einer Quelle abgeleitet hat, und gleicht es mit dem eigenen Objektbestand ab — mit einem Wert je Objekt, der sich auf die geprüften Merkmale zurückführen lässt.', input: [ - 'Regionen: Zürich, Winterthur, Zug, Basel, Bern und St. Gallen', - 'Segmente: Büro, Retail, Gastronomie, Logistik, Gewerbe und Produktion', - 'Öffentliche Quellen wie SHAB-Meldungen, Zefix-Einträge und Firmenwebseiten', - 'Simulierte Feeds zu Ausschreibungen, Standortmeldungen und Medienberichten', - 'Objekt- und Portfoliostammdaten aus dem Arbeitsbereich', + 'Nachfrageprofile aus Livias Recherche-Leads', + 'Objektdatenbasis aus «Meine Objekte» — Standort, Fläche, Nutzung, Verfügbarkeit, Ausstattung', + 'Bestehende Mietverhältnisse, um belegte von freien Flächen zu unterscheiden', + 'Laufende Vorgänge aus dem CRM, damit bekannte Nachfrage nicht doppelt erscheint', ], coreFlow: [ - 'Nora sichtet die freigegebenen Quellen und Feeds zu den eingestellten Regionen und Segmenten.', - 'Jede Meldung wird als Signalart eingeordnet — zum Beispiel Umzug, Expansion oder Filialschliessung.', - 'Zu jedem Signal vergibt sie eine Konfidenz und hinterlegt die Quelle mit Datum.', - 'Liegt die Konfidenz unter der Review-Schwelle, wandert das Signal in die Review-Queue statt in den Versand.', - 'Verifizierte Signale werden einer Region und, wenn möglich, einem konkreten Objekt zugeordnet.', - 'Wochen-Mail, Lead-Liste und Markteinschätzung entstehen als Vorschlag und gehen erst nach Freigabe raus.', + 'Aus dem Nachfrageprofil die Anforderungen lesen, die darin tatsächlich stehen.', + 'Den Kandidatenkreis bilden: freie und bald frei werdende Objekte, auf Wunsch auch belegte.', + 'Jedes Objekt gegen die gelesenen Anforderungen prüfen — Standort, Fläche, Nutzung, Verfügbarkeit und, wo das Profil sie nennt, Erreichbarkeit, Parkierung, Ausbau und Sichtbarkeit.', + 'Merkmale, zu denen der Bestand nichts sagt, als nicht bekannt führen statt als erfüllt.', + 'Die Übereinstimmung als Wert von 0 bis 100 ausgeben und die Liste danach ordnen.', + 'Den Lauf mit Zeitpunkt, Kandidatenzahl und bestem Wert protokollieren.', ], output: [ - 'Wochen-Mail mit den wichtigsten Marktsignalen, dienstags um 08:00 Uhr', - 'Einzelne Marktsignale mit Signalart, Konfidenz, Quelle und Regionsbezug', - 'Lead-Liste aus Umzugs- und Expansionssignalen zur Übergabe an das CRM', - 'Monatliche Markteinschätzung je Region und Segment', - 'Review-Queue mit den unsicheren Signalen zur Sichtung durch das Team', + 'Trefferliste der Objekte mit Match-Wert, sortiert vom besten Treffer abwärts', + 'Je Objekt die geprüften Kriterien mit ihrer Begründung', + 'Hinweis, wie viel sich am jeweiligen Objekt überhaupt prüfen liess', + 'Protokolleintrag je durchgeführtem Abgleich', ], }, responsibilities: [ - 'Laufende Beobachtung der freigegebenen Quellen und Feeds in den eingestellten Regionen', - 'Nachvollziehbare Einordnung jedes Signals nach Signalart und Konfidenz', - 'Saubere Trennung zwischen verifizierten Signalen und Fällen für die Review-Queue', - 'Zuordnung der Signale zu Regionen und, wo belegbar, zu einzelnen Objekten', - 'Vorbereitung von Wochen-Mail, Lead-Liste und monatlicher Markteinschätzung als Vorschlag', + 'Nachfrageprofile in prüfbare Anforderungen übersetzen', + 'Den Objektbestand nach diesen Anforderungen abgleichen, ohne fehlende Angaben zu erfinden', + 'Den Match-Wert so ausweisen, dass er sich auf einzelne Kriterien zurückführen lässt', + 'Belegte Objekte nur auf ausdrücklichen Wunsch in den Kandidatenkreis nehmen', + 'Jeden durchgeführten Abgleich nachvollziehbar protokollieren', ], /* @@ -116,11 +119,11 @@ export const noraAgent: TeamAgent = { type: AgentChannelType.EMAIL, direction: AgentChannelDirection.OUTBOUND, description: - 'Versand der freigegebenen Wochen-Mail und der monatlichen Markteinschätzung an Marktbeobachtung, Akquisition und Bewirtschaftung. Nora nimmt über diesen Kanal keine Post entgegen.', + 'Versand von Matching-Ergebnissen an Vermarktung und Bewirtschaftung, wenn ein Abgleich passende Objekte ergeben hat. Nora nimmt über diesen Kanal keine Post entgegen.', status: AgentConnectionStatus.CONNECTED, enabled: true, config: { - displayName: 'Nora — Markt-Scout', + displayName: 'Nora — Marktchancen und Leads', senderAddress: 'nora@property-on.ch', defaultRecipients: [ 'marktbeobachtung@property-on.ch', @@ -232,8 +235,8 @@ export const noraAgent: TeamAgent = { '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. ' + + 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, zählt es nicht als Nichterfüllung, ' + + 'senkt aber die Abdeckung — und je weniger geprüft werden konnte, desto stärker wird der Wert gedämpft. ' + '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.', }, diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index aa881fc..d0d654e 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -4109,7 +4109,7 @@ export const mockProperties: Property[] = [ rentPricePerSqm: 182, totalRentMonthly: 81900, availabilityDate: '2026-01-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'ERP_IMPORT', confidenceScore: 0.92, dataQuality: { @@ -4144,6 +4144,10 @@ export const mockProperties: Property[] = [ breakoutOption: false, schattenmarktRelease: { enabled: false }, organizationId: 'org-wincasa', + currentTenant: 'Rhein Technik AG', + leaseTerm: '10 Jahre', + leaseStartDate: '2021-01-01', + leaseEndDate: '2031-12-31', createdAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z', }, @@ -4159,7 +4163,7 @@ export const mockProperties: Property[] = [ rentPricePerSqm: 160, totalRentMonthly: 96000, availabilityDate: '2026-07-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'ERP_IMPORT', confidenceScore: 0.91, dataQuality: { @@ -4194,6 +4198,10 @@ export const mockProperties: Property[] = [ breakoutOption: false, schattenmarktRelease: { enabled: false }, organizationId: 'org-wincasa', + currentTenant: 'Seefeld Consulting AG', + leaseTerm: '5 Jahre', + leaseStartDate: '2023-07-01', + leaseEndDate: '2028-06-30', createdAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z', }, @@ -4300,7 +4308,7 @@ export const mockProperties: Property[] = [ rentPricePerSqm: 312, totalRentMonthly: 51480, availabilityDate: '2026-07-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'ERP_IMPORT', confidenceScore: 0.91, dataQuality: { @@ -4335,6 +4343,10 @@ export const mockProperties: Property[] = [ breakoutOption: false, schattenmarktRelease: { enabled: false }, organizationId: 'org-wincasa', + currentTenant: 'Lienhard Gastro GmbH', + leaseTerm: '9 Jahre', + leaseStartDate: '2021-01-01', + leaseEndDate: '2030-12-31', createdAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z', }, @@ -4354,7 +4366,7 @@ export const mockProperties: Property[] = [ areaSqm: 320, rentPricePerSqm: 380, availabilityDate: '2026-06-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'DIRECT', confidenceScore: 0.80, dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] }, @@ -4365,6 +4377,11 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.', organizationId: 'org-wincasa', + currentTenant: 'Nordwest Handels AG', + leaseTerm: '6 Jahre', + leaseStartDate: '2023-10-01', + leaseEndDate: '2029-09-30', + breakoutOption: false, createdAt: '2026-02-14T09:30:00Z', updatedAt: '2026-02-14T09:30:00Z', }, @@ -4406,7 +4423,7 @@ export const mockProperties: Property[] = [ areaSqm: 240, rentPricePerSqm: 295, availabilityDate: '2026-07-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'DIRECT', confidenceScore: 0.78, dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] }, @@ -4417,6 +4434,11 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.', organizationId: 'org-wincasa', + currentTenant: 'Weber Präzision GmbH', + leaseTerm: '12 Jahre', + leaseStartDate: '2020-10-01', + leaseEndDate: '2032-09-30', + breakoutOption: false, createdAt: '2026-04-02T14:15:00Z', updatedAt: '2026-04-02T14:15:00Z', }, @@ -4432,7 +4454,7 @@ export const mockProperties: Property[] = [ areaSqm: 185, rentPricePerSqm: 360, availabilityDate: '2026-08-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'DIRECT', confidenceScore: 0.72, dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] }, @@ -4443,6 +4465,11 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.MEDIUM, description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.', organizationId: 'org-wincasa', + currentTenant: 'Alpstein Pharma AG', + leaseTerm: '7 Jahre', + leaseStartDate: '2022-01-01', + leaseEndDate: '2029-12-31', + breakoutOption: false, createdAt: '2026-01-20T10:00:00Z', updatedAt: '2026-04-15T08:00:00Z', }, @@ -4458,7 +4485,7 @@ export const mockProperties: Property[] = [ areaSqm: 1200, rentPricePerSqm: 130, availabilityDate: '2026-06-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'DIRECT', confidenceScore: 0.88, dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] }, @@ -4469,6 +4496,11 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.LOW, description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.', organizationId: 'org-wincasa', + currentTenant: 'Bürgin Treuhand AG', + leaseTerm: '8 Jahre', + leaseStartDate: '2022-07-01', + leaseEndDate: '2030-06-30', + breakoutOption: false, createdAt: '2026-04-28T16:00:00Z', updatedAt: '2026-04-28T16:00:00Z', }, @@ -4484,7 +4516,7 @@ export const mockProperties: Property[] = [ areaSqm: 450, rentPricePerSqm: 260, availabilityDate: '2026-09-01', - availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, + availabilityStatus: AvailabilityStatus.OCCUPIED, sourceType: 'DIRECT', confidenceScore: 0.70, dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] }, @@ -4495,6 +4527,11 @@ export const mockProperties: Property[] = [ riskLevel: RiskLevel.MEDIUM, description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.', organizationId: 'org-wincasa', + currentTenant: 'Helvetia Logistik AG', + leaseTerm: '10 Jahre', + leaseStartDate: '2021-04-01', + leaseEndDate: '2031-03-31', + breakoutOption: false, createdAt: '2025-12-10T08:00:00Z', updatedAt: '2026-03-20T12:00:00Z', },