Files
property-match/src/features/matching/scoreCalculator.ts
T
Benjamin Sutter efc406ace9 feat: role-aware UX, market intelligence, score fix & layout scroll
- Role-based workspace access: Property Manager gets Supply+Demand,
  Operations restricted to Super Admin + Reviewer only
- Root redirect routes each persona to their first workspace
- Match Center: replaced 3-panel with auto-sorted flat list + drawer
- AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern)
- CompareTray hidden on non-Demand routes; Vergleichen removed from Supply
- LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables
- NegotiationInsightsPanel: price positioning, active demand, selling arguments
- scoreCalculator: cap hardMatchScore and softFactorScore to max 100
- Layout: add display:flex to overflow:hidden wrappers so inner scroll works
  (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 00:58:10 +02:00

415 lines
17 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 { Need } from '../../domain/need'
import type { Property } from '../../domain/property'
import type { ScoreFactor } from '../../domain/match'
import { ResultType, AvailabilityStatus, AssetType } from '../../domain/enums'
import {
HARD_FILTER,
DATA_QUALITY_MODIFIER,
CONFIDENCE_MODIFIER,
HARD_CRITERION_KEYS,
SOFT_FACTOR_KEYS,
DEFAULT_SCORING_PROFILES,
} from '../../domain/scoring'
import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFactorKey } from '../../domain/scoring'
import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer'
import { generateNextBestActions } from './rankingEngine'
// ── Profile resolution ────────────────────────────────────────────────────────
function resolveProfile(need: Need, property: Property): ScoringWeightProfile {
const base = { ...(DEFAULT_SCORING_PROFILES[property.assetType] ?? DEFAULT_SCORING_PROFILES.DEFAULT) }
const np = need.weightingProfile
if (!np) return base
// Apply need's custom core weights, then renormalize the full profile to 1.00
const CORE = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'flexibility']
for (const key of CORE) {
if (typeof np[key] === 'number') base[key] = np[key]
}
const total = Object.values(base).reduce((s, v) => s + v, 0)
if (total > 0) for (const key of Object.keys(base)) base[key] /= total
return base
}
// ── Hard Filters ──────────────────────────────────────────────────────────────
export function applyHardFilters(need: Need, property: Property): HardFilterResult {
const assetOk = property.assetType === need.assetType
|| property.assetType === AssetType.MIXED
|| need.assetType === AssetType.UNKNOWN
if (!assetOk) {
return { excluded: true, reason: `Nutzungstyp ${property.assetType} stimmt nicht mit ${need.assetType} überein`, severePenalty: 0 }
}
// Area: hard exclude below tolerance
const areaMin = need.requiredArea?.min ?? 0
const propArea = property.areaSqmMin ?? property.areaSqm
if (areaMin > 0 && propArea < areaMin * HARD_FILTER.AREA_MIN_TOLERANCE) {
return {
excluded: true,
reason: `Fläche ${propArea} m² unterschreitet Minimum ${areaMin} m² um mehr als ${Math.round((1 - HARD_FILTER.AREA_MIN_TOLERANCE) * 100)}%`,
severePenalty: 0,
}
}
// Region exclusion
const city = property.location.city.toLowerCase()
const excluded = (need.excludedLocations ?? []).map(l => l.toLowerCase())
if (excluded.some(e => city.includes(e) || e.includes(city))) {
return { excluded: true, reason: `Standort ${property.location.city} ist ausgeschlossen`, severePenalty: 0 }
}
// Budget: hard exclude if massively over
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
if (maxBudget > 0 && property.rentPricePerSqm > maxBudget * HARD_FILTER.BUDGET_EXCLUSION_RATIO) {
return {
excluded: true,
reason: `Miete CHF ${property.rentPricePerSqm}/m² überschreitet Budget CHF ${maxBudget}/m² um mehr als ${Math.round((HARD_FILTER.BUDGET_EXCLUSION_RATIO - 1) * 100)}%`,
severePenalty: 0,
}
}
// Usage/zoning: occupied property is severe penalty, not exclude
if (property.availabilityStatus === AvailabilityStatus.OCCUPIED) {
return { excluded: false, reason: undefined, severePenalty: 25 }
}
return { excluded: false, reason: undefined, severePenalty: 0 }
}
// ── Hard Criterion Scorers ────────────────────────────────────────────────────
function scoreArea(need: Need, property: Property, weight: number): ScoreFactor {
const { min = 0, max = Infinity } = need.requiredArea ?? {}
const area = property.areaSqm
const areaMin = property.areaSqmMin ?? area
const areaMax = property.areaSqmMax ?? area
let score: number
let explanation: string
// Flexible property — check range overlap
const overlap = areaMin <= max && areaMax >= min
if (overlap) {
score = 100
explanation = `Fläche ${areaMin === areaMax ? `${area}` : `${areaMin}${areaMax}`} m² deckt Bedarf ${min}${max} m² ab`
} else if (area > max) {
const ratio = area / max
score = ratio <= HARD_FILTER.AREA_MAX_RATIO
? Math.max(40, Math.round(100 - (ratio - 1) * 50))
: 20
explanation = `Fläche ${area} m² überschreitet Maximum ${max} m² (${Math.round((ratio - 1) * 100)}% zu viel)`
} else {
// Area between tolerance and min — mild penalty
const ratio = area / min
score = Math.round(40 + ratio * 30)
explanation = `Fläche ${area} m² leicht unter Minimum ${min}`
}
return { criterion: 'area', weight, score, contribution: score * weight, explanation }
}
function scoreLocation(need: Need, property: Property, weight: number): ScoreFactor {
const city = property.location.city.toLowerCase()
const canton = (property.location.canton ?? '').toLowerCase()
const preferred = (need.preferredLocations ?? []).map(l => l.toLowerCase())
let score: number
let explanation: string
if (preferred.length === 0) {
score = 70
explanation = 'Kein Standortwunsch — neutral bewertet'
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
score = 100
explanation = `Standort ${property.location.city} entspricht Präferenz`
} else if (canton && preferred.some(p => p.includes(canton) || canton.includes(p))) {
score = 60
explanation = `Gleicher Kanton wie Präferenz (${property.location.canton})`
} else {
score = 35
explanation = `Standort ${property.location.city} nicht in Präferenzliste`
}
return { criterion: 'location', weight, score, contribution: score * weight, explanation }
}
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
const rent = property.rentPricePerSqm
let score: number
let explanation: string
if (maxBudget <= 0) {
score = 60
explanation = 'Kein Budget angegeben — neutral bewertet'
} else if (rent <= maxBudget) {
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
} else {
score = 20
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
}
}
return { criterion: 'budget', weight, score, contribution: score * weight, explanation }
}
function scoreTiming(need: Need, property: Property, weight: number): ScoreFactor {
const isFutureSignal = property.resultType === ResultType.FUTURE_AVAILABILITY
const rawDate = property.availabilityDate
const propDate = rawDate && rawDate !== '' ? new Date(rawDate) : null
const earliest = need.timing?.earliestMoveIn ? new Date(need.timing.earliestMoveIn) : null
const latest = need.timing?.latestMoveIn ? new Date(need.timing.latestMoveIn) : null
const graceMs = HARD_FILTER.TIMING_GRACE_DAYS * 86_400_000
let score: number
let explanation: string
// CRITICAL RULE: Future availability is never treated as confirmed
if (isFutureSignal) {
if (!propDate) {
score = 30
explanation = 'Zukünftiges Signal — kein Datum, Verfügbarkeit unbestätigt'
} else if (latest && propDate.getTime() > latest.getTime() + graceMs) {
score = 20
explanation = `Zukünftiges Signal — erwartet ${rawDate}, nach gewünschtem Zeitfenster (unbestätigt)`
} else if (earliest && propDate.getTime() < earliest.getTime()) {
score = 50
explanation = `Zukünftiges Signal — erwartet ${rawDate}, vor gewünschtem Einzug (unbestätigt)`
} else {
score = 42
explanation = `Zukünftiges Signal — Zeitfenster passt, Verfügbarkeit jedoch unbestätigt`
}
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
const isNow = property.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW
|| property.availabilityStatus === AvailabilityStatus.AVAILABLE_SOON
if (isNow) {
const tooEarly = earliest && new Date() < earliest
score = tooEarly ? 80 : 100
explanation = tooEarly
? `Sofort verfügbar — Einzug jedoch erst ab ${need.timing?.earliestMoveIn} geplant`
: 'Sofort verfügbar — entspricht Verfügbarkeitswunsch'
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
if (!propDate) {
score = 38
explanation = 'Kein Verfügbarkeitsdatum angegeben'
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
if (earliest && latest) {
const t = propDate.getTime()
if (t >= earliest.getTime() && t <= latest.getTime()) {
score = 95
explanation = `Verfügbar ${rawDate} liegt im Einzugsfenster`
} else if (t < earliest.getTime()) {
const diff = earliest.getTime() - t
score = diff < graceMs ? 80 : 65
explanation = `Verfügbar ${rawDate} vor gewünschtem Einzug — kurze Leerstandszeit`
} else {
const diff = t - latest.getTime()
score = diff < graceMs ? 55 : 28
explanation = `Verfügbar ${rawDate} nach gewünschtem Zeitfenster`
}
} else {
score = 65
explanation = `Verfügbar ${rawDate}`
}
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
// ── Soft Factor Scorer ────────────────────────────────────────────────────────
function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property): ScoreFactor {
const sf = property.softFactors
const hf = property.hardFacts
const rawValue = (() => {
switch (key) {
case 'prestige': return sf?.prestigeScore ?? sf?.prestige
case 'accessibility': return sf?.commuterAccessScore ?? sf?.accessibility
?? (hf?.publicTransportScore !== undefined ? hf.publicTransportScore / 10 : undefined)
case 'expansionPotential': return sf?.expansionPotentialScore
case 'flexibility': return sf?.flexibilityScore
case 'visibility': return sf?.visibilityScore
case 'footfall': return sf?.footfallScore
case 'talentAccess': return sf?.talentAccessScore ?? sf?.talentAccess
case 'esg': return sf?.esgScore
case 'taxEnvironment': return sf?.taxEnvironmentScore
default: return undefined
}
})()
if (rawValue === undefined || rawValue === null) {
// Missing data → neutral 50 (does not help, does not hurt)
return {
criterion: key,
weight,
score: 50,
contribution: 50 * weight,
explanation: `${key}: keine Daten verfügbar — neutral bewertet`,
}
}
// Soft factor values are 01 scale → convert to 0100
const score = Math.round(Math.min(100, Math.max(0, rawValue * 100)))
const LABELS: Record<SoftFactorKey, string> = {
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`,
}
}
// ── Modifier Calculators ──────────────────────────────────────────────────────
export function calcDataQualityModifier(property: Property): number {
const s = property.dataQuality?.score ?? 0.5
if (s >= 0.85) return DATA_QUALITY_MODIFIER.EXCELLENT
if (s >= 0.70) return DATA_QUALITY_MODIFIER.GOOD
if (s >= 0.55) return DATA_QUALITY_MODIFIER.FAIR
if (s >= 0.40) return DATA_QUALITY_MODIFIER.POOR
return DATA_QUALITY_MODIFIER.CRITICAL
}
export function calcConfidenceModifier(property: Property): number {
let mod = 0
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
} else {
// VERIFIED_PORTFOLIO
mod += property.confidenceScore >= 0.80
? CONFIDENCE_MODIFIER.VERIFIED_HIGH
: CONFIDENCE_MODIFIER.VERIFIED_MEDIUM
}
if (property.confidenceScore < 0.50) {
mod += CONFIDENCE_MODIFIER.LOW_CONFIDENCE
}
return mod
}
// ── Main Engine Function ──────────────────────────────────────────────────────
export function calculateScore(need: Need, property: Property): MatchEngineOutput {
const hardFilter = applyHardFilters(need, property)
if (hardFilter.excluded) {
return {
propertyId: property.id,
needId: need.id,
excluded: true,
excludedReason: hardFilter.reason,
finalScore: 0,
hardMatchScore: 0,
softFactorScore: 0,
dataQualityModifier: 0,
confidenceModifier: 0,
positiveFactors: [],
negativeFactors: [],
allHardFactors: [],
allSoftFactors: [],
tradeOffs: [],
risks: [],
missingData: identifyMissingData(property, need),
nextBestActions: [],
}
}
const profile = resolveProfile(need, property)
// ── Hard criteria scoring ──────────────────────────────────────────────────
const hardFactors: ScoreFactor[] = [
scoreArea(need, property, profile.area),
scoreLocation(need, property, profile.location),
scoreBudget(need, property, profile.budget),
scoreTiming(need, property, profile.timing),
]
const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0)
const hardRaw = hardFactors.reduce((s, f) => s + f.contribution, 0)
const hardMatchScore = hardWeightSum > 0 ? Math.min(100, Math.round(hardRaw / hardWeightSum)) : 0
// ── Soft factor scoring ────────────────────────────────────────────────────
const softFactors: ScoreFactor[] = SOFT_FACTOR_KEYS
.filter(k => (profile[k] ?? 0) > 0)
.map(k => scoreSoftFactor(k, profile[k], property))
const softWeightSum = SOFT_FACTOR_KEYS.reduce((s, k) => s + (profile[k] ?? 0), 0)
const softRaw = softFactors.reduce((s, f) => s + f.contribution, 0)
const softFactorScore = softWeightSum > 0 ? Math.min(100, Math.round(softRaw / softWeightSum)) : 50
// ── Modifiers ──────────────────────────────────────────────────────────────
const dqMod = calcDataQualityModifier(property)
const confMod = calcConfidenceModifier(property)
// ── Final score: weighted sum of both groups + modifiers ──────────────────
// Each group already normalized 0100; combine per SCORE_SPLIT, then apply modifiers
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification ──────────────────────────────────────────────────
const allFactors = [...hardFactors, ...softFactors]
const THRESHOLD_POSITIVE = 70
const THRESHOLD_NEGATIVE = 45
const positiveFactors = allFactors
.filter(f => f.score >= THRESHOLD_POSITIVE)
.sort((a, b) => b.contribution - a.contribution)
.slice(0, 4)
const negativeFactors = allFactors
.filter(f => f.score < THRESHOLD_NEGATIVE)
.sort((a, b) => a.contribution - b.contribution)
.slice(0, 4)
const tradeOffs = analyzeTradeOffs(hardFactors, softFactors, need, property)
const risks = analyzeRisks(property, hardFactors)
const missingData = identifyMissingData(property, need)
const output: MatchEngineOutput = {
propertyId: property.id,
needId: need.id,
excluded: false,
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier: dqMod,
confidenceModifier: confMod,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
allSoftFactors: softFactors,
tradeOffs,
risks,
missingData,
nextBestActions: [], // filled by rankingEngine
}
output.nextBestActions = generateNextBestActions(output, property, need)
return output
}