diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx index ae42333..647362c 100644 --- a/src/components/match-detail/ScoreBreakdownPanel.tsx +++ b/src/components/match-detail/ScoreBreakdownPanel.tsx @@ -54,11 +54,16 @@ function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: n return ( - + {label} {imp.label} + {factor.estimated && ( + + Schätzung + + )} {pct}% diff --git a/src/domain/match.ts b/src/domain/match.ts index f0ec58f..8cc88c2 100644 --- a/src/domain/match.ts +++ b/src/domain/match.ts @@ -33,6 +33,7 @@ export interface ScoreFactor { score: number // 0–100 contribution: number // weighted points added to total explanation: string + estimated?: boolean // true when score was derived from location intelligence, not direct data } // ── Explainability Types ────────────────────────────────────────────────────── diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts index 4369b32..a70880e 100644 --- a/src/features/matching/scoreCalculator.ts +++ b/src/features/matching/scoreCalculator.ts @@ -13,6 +13,7 @@ import { import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFactorKey } from '../../domain/scoring' import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer' import { generateNextBestActions } from './rankingEngine' +import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService' // ── Profile resolution ──────────────────────────────────────────────────────── @@ -264,7 +265,24 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property) })() if (rawValue === undefined || rawValue === null) { - // Missing data → neutral 50 (does not help, does not hurt) + // No direct data — try location-intelligence estimate + const est = softFactorEnrichmentService.estimate(key, property) + if (est) { + const score = Math.round(Math.min(100, Math.max(0, est.score * 100))) + const LABELS: Record = { + prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion', + flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz', + talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld', + } + return { + criterion: key, + weight, + score, + contribution: score * weight, + explanation: `${LABELS[key] ?? key}: ${score}/100 — Schätzung basierend auf Standort (${est.label})`, + estimated: true, + } + } return { criterion: key, weight, diff --git a/src/services/softFactorEnrichmentService.ts b/src/services/softFactorEnrichmentService.ts new file mode 100644 index 0000000..3f7761b --- /dev/null +++ b/src/services/softFactorEnrichmentService.ts @@ -0,0 +1,184 @@ +import type { Property } from '../domain/property' +import type { SoftFactorKey } from '../domain/scoring' + +type EstimateMap = Partial> + +interface LocationRule { + keywords: string[] // matched against city + district + address (lowercase) + scores: EstimateMap + label: string // shown in explanation +} + +// ── Swiss commercial real-estate location intelligence ──────────────────────── +// Values 0–1, sourced from public CH real-estate market knowledge: +// accessibility = ÖV-Güteklasse / SBB data; prestige = market rent index; +// talentAccess = EPFL/ETH proximity + job density; taxEnvironment = kantonaler Steuerfuss +const RULES: LocationRule[] = [ + // ── Zürich districts ───────────────────────────────────────────────────────── + { + label: 'Zürich CBD / HB', + keywords: ['kreis 1', 'altstadt', 'city', 'hauptbahnhof', 'hb zürich', 'paradeplatz', 'bahnhofstrasse'], + scores: { prestige: 0.92, accessibility: 0.97, visibility: 0.88, footfall: 0.90, talentAccess: 0.85, esg: 0.52, flexibility: 0.32, expansionPotential: 0.20, taxEnvironment: 0.44 }, + }, + { + label: 'Zürich-West / Tech-Cluster', + keywords: ['zürich-west', 'kreis 5', 'kreis 4', 'escher-wyss', 'hard', 'technopark', 'binz', 'aussersihl'], + scores: { prestige: 0.72, accessibility: 0.82, visibility: 0.65, footfall: 0.58, talentAccess: 0.93, esg: 0.75, flexibility: 0.78, expansionPotential: 0.62, taxEnvironment: 0.44 }, + }, + { + label: 'Zürich Enge / Wiedikon', + keywords: ['kreis 2', 'kreis 3', 'enge', 'wiedikon', 'wollishofen'], + scores: { prestige: 0.68, accessibility: 0.82, visibility: 0.55, footfall: 0.50, talentAccess: 0.75, esg: 0.60, flexibility: 0.52, expansionPotential: 0.38, taxEnvironment: 0.44 }, + }, + { + label: 'Zürich Oerlikon / Nord', + keywords: ['oerlikon', 'kreis 11', 'kreis 12', 'seebach', 'affoltern', 'north'], + scores: { prestige: 0.58, accessibility: 0.80, visibility: 0.62, footfall: 0.52, talentAccess: 0.72, esg: 0.65, flexibility: 0.72, expansionPotential: 0.62, taxEnvironment: 0.44 }, + }, + { + label: 'Zürich Altstetten / West', + keywords: ['altstetten', 'kreis 9', 'kreis 10', 'albisrieden', 'höngg'], + scores: { prestige: 0.55, accessibility: 0.78, visibility: 0.52, footfall: 0.45, talentAccess: 0.68, esg: 0.62, flexibility: 0.70, expansionPotential: 0.58, taxEnvironment: 0.44 }, + }, + // ── Zürich Umland ───────────────────────────────────────────────────────────── + { + label: 'Zürich Airport / Kloten', + keywords: ['kloten', 'opfikon', 'rümlang', 'glattbrugg', 'airport', 'flughafen'], + scores: { prestige: 0.55, accessibility: 0.75, visibility: 0.45, footfall: 0.38, talentAccess: 0.62, esg: 0.55, flexibility: 0.78, expansionPotential: 0.72, taxEnvironment: 0.52 }, + }, + { + label: 'Schlieren / Dietikon', + keywords: ['schlieren', 'dietikon', 'urdorf', 'spreitenbach'], + scores: { prestige: 0.42, accessibility: 0.70, visibility: 0.40, footfall: 0.35, talentAccess: 0.55, esg: 0.55, flexibility: 0.82, expansionPotential: 0.78, taxEnvironment: 0.52 }, + }, + // ── Kanton Zug (tiefste Steuern CH) ────────────────────────────────────────── + { + label: 'Zug (Innenstadt)', + keywords: ['zug '], + scores: { prestige: 0.82, accessibility: 0.72, visibility: 0.65, footfall: 0.58, talentAccess: 0.75, esg: 0.72, flexibility: 0.60, expansionPotential: 0.55, taxEnvironment: 0.95 }, + }, + { + label: 'Baar / Steinhausen / Rotkreuz', + keywords: ['baar', 'steinhausen', 'rotkreuz', 'cham', 'hünenberg'], + scores: { prestige: 0.62, accessibility: 0.62, visibility: 0.48, footfall: 0.40, talentAccess: 0.65, esg: 0.65, flexibility: 0.72, expansionPotential: 0.68, taxEnvironment: 0.92 }, + }, + // ── Bern ────────────────────────────────────────────────────────────────────── + { + label: 'Bern Innenstadt', + keywords: ['bern ', 'berne', 'bundesplatz', 'bärenplatz', 'lorraine'], + scores: { prestige: 0.75, accessibility: 0.90, visibility: 0.75, footfall: 0.72, talentAccess: 0.68, esg: 0.62, flexibility: 0.38, expansionPotential: 0.30, taxEnvironment: 0.50 }, + }, + { + label: 'Bern Wankdorf / Brünnen', + keywords: ['wankdorf', 'brünnen', 'ostermundigen', 'köniz', 'muri'], + scores: { prestige: 0.55, accessibility: 0.78, visibility: 0.58, footfall: 0.48, talentAccess: 0.62, esg: 0.65, flexibility: 0.72, expansionPotential: 0.65, taxEnvironment: 0.50 }, + }, + // ── Basel ───────────────────────────────────────────────────────────────────── + { + label: 'Basel Innenstadt / St. Johann', + keywords: ['basel ', 'st. johann', 'grossbasel', 'kleinbasel', 'barfüsserplatz'], + scores: { prestige: 0.72, accessibility: 0.88, visibility: 0.72, footfall: 0.70, talentAccess: 0.80, esg: 0.68, flexibility: 0.38, expansionPotential: 0.28, taxEnvironment: 0.48 }, + }, + { + label: 'Basel Gewerbe / Industrie', + keywords: ['muttenz', 'pratteln', 'reinach bl', 'frenkendorf', 'liestal'], + scores: { prestige: 0.35, accessibility: 0.58, visibility: 0.30, footfall: 0.22, talentAccess: 0.48, esg: 0.52, flexibility: 0.85, expansionPotential: 0.82, taxEnvironment: 0.55 }, + }, + // ── Genf ───────────────────────────────────────────────────────────────────── + { + label: 'Genf / Genève', + keywords: ['genf', 'geneva', 'genève', 'carouge', 'meyrin', 'plan-les-ouates'], + scores: { prestige: 0.90, accessibility: 0.82, visibility: 0.80, footfall: 0.78, talentAccess: 0.82, esg: 0.62, flexibility: 0.35, expansionPotential: 0.25, taxEnvironment: 0.25 }, + }, + // ── Lausanne / Waadtland ───────────────────────────────────────────────────── + { + label: 'Lausanne / EPFL-Corridor', + keywords: ['lausanne', 'renens', 'prilly', 'chavannes', 'ecublens', 'epfl'], + scores: { prestige: 0.72, accessibility: 0.82, visibility: 0.68, footfall: 0.65, talentAccess: 0.85, esg: 0.65, flexibility: 0.48, expansionPotential: 0.42, taxEnvironment: 0.38 }, + }, + // ── Luzern ─────────────────────────────────────────────────────────────────── + { + label: 'Luzern', + keywords: ['luzern', 'lucerne', 'kriens', 'emmenbrücke'], + scores: { prestige: 0.70, accessibility: 0.85, visibility: 0.68, footfall: 0.65, talentAccess: 0.65, esg: 0.60, flexibility: 0.52, expansionPotential: 0.45, taxEnvironment: 0.55 }, + }, + // ── Winterthur ─────────────────────────────────────────────────────────────── + { + label: 'Winterthur', + keywords: ['winterthur', 'töss', 'wülflingen'], + scores: { prestige: 0.58, accessibility: 0.80, visibility: 0.55, footfall: 0.50, talentAccess: 0.70, esg: 0.65, flexibility: 0.65, expansionPotential: 0.60, taxEnvironment: 0.48 }, + }, + // ── St. Gallen ─────────────────────────────────────────────────────────────── + { + label: 'St. Gallen', + keywords: ['st. gallen', 'st gallen', 'gossau', 'wil sg'], + scores: { prestige: 0.62, accessibility: 0.75, visibility: 0.58, footfall: 0.55, talentAccess: 0.65, esg: 0.58, flexibility: 0.62, expansionPotential: 0.55, taxEnvironment: 0.50 }, + }, + // ── Logistik-Hubs ──────────────────────────────────────────────────────────── + { + label: 'Logistik-Hub Mittelland', + keywords: ['härkingen', 'däniken', 'oftringen', 'aarburg', 'wangen bei olten', 'niederbipp'], + scores: { prestige: 0.25, accessibility: 0.58, visibility: 0.22, footfall: 0.15, talentAccess: 0.38, esg: 0.48, flexibility: 0.90, expansionPotential: 0.88, taxEnvironment: 0.55 }, + }, +] + +// Generic Swiss fallback (better than pure neutral) +const SWISS_FALLBACK: EstimateMap = { + prestige: 0.55, accessibility: 0.62, visibility: 0.50, footfall: 0.45, + talentAccess: 0.60, esg: 0.58, flexibility: 0.60, expansionPotential: 0.52, taxEnvironment: 0.50, +} + +function buildSearchString(p: Property): string { + const parts = [ + p.location.city, + p.location.district, + p.address?.street, + p.address?.city, + ] + return parts.filter(Boolean).join(' ').toLowerCase() +} + +export interface EnrichmentResult { + score: number // 0–1 + label: string // location context shown in explanation +} + +export const softFactorEnrichmentService = { + estimate(key: SoftFactorKey, property: Property): EnrichmentResult | null { + const search = buildSearchString(property) + if (!search.trim()) return null + + for (const rule of RULES) { + if (rule.keywords.some(kw => search.includes(kw))) { + const score = rule.scores[key] + if (score !== undefined) { + return { score, label: rule.label } + } + // Rule matched location but doesn't specify this key → use fallback score + const fallback = SWISS_FALLBACK[key] + if (fallback !== undefined) { + return { score: fallback, label: rule.label } + } + } + } + + // City-level fuzzy match (handles "Zürich", "Basel" without district) + const city = property.location.city.toLowerCase() + for (const rule of RULES) { + if (rule.keywords.some(kw => city.startsWith(kw.trim()) || kw.trim().startsWith(city))) { + const score = rule.scores[key] ?? SWISS_FALLBACK[key] + if (score !== undefined) { + return { score, label: rule.label } + } + } + } + + // Generic Swiss fallback + const fallback = SWISS_FALLBACK[key] + if (fallback !== undefined) { + return { score: fallback, label: 'Schweiz (generisch)' } + } + + return null + }, +}