@@ -0,0 +1,184 @@
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 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
} ,
}