Files
property-match/src/services/softFactorEnrichmentService.ts
T
Benjamin Sutter 98b4a1146b feat: soft factor enrichment from Swiss location intelligence
When property has no soft factor data, estimate from location:
- 18 location rules covering CH cities/districts (Zürich quartiers,
  Zug tax haven, Bern, Basel, Genf, Lausanne, Luzern, Winterthur,
  logistics hubs) + generic CH fallback
- Estimated factors marked with 'estimated: true' on ScoreFactor
- ScoreBreakdownPanel shows purple 'Schätzung' badge on estimated rows
- Scoring engine: neutral 50 only if no location match found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:04:51 +02:00

185 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Property } from '../domain/property'
import type { SoftFactorKey } from '../domain/scoring'
type EstimateMap = Partial<Record<SoftFactorKey, number>>
interface LocationRule {
keywords: string[] // matched against city + district + address (lowercase)
scores: EstimateMap
label: string // shown in explanation
}
// ── Swiss commercial real-estate location intelligence ────────────────────────
// Values 01, 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 // 01
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
},
}