9df5d285f5
Score formula simplified to: finalScore = round(hard×60% + soft×40%) Removes dataQualityModifier, confidenceModifier, mustHaveEval.scoreImpact — the breakdown panel now always matches the displayed score. Also removes 'industrie groove' from Zürich-only location signals — the phrase describes an aesthetic preference, not a district reference. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
632 lines
27 KiB
TypeScript
632 lines
27 KiB
TypeScript
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'
|
||
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
|
||
import { scoreMustHaves } from './mustHaveScorer'
|
||
|
||
// ── 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',
|
||
'visibility', 'footfall', 'talentAccess', 'esg', 'taxEnvironment',
|
||
]
|
||
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} m²`
|
||
}
|
||
|
||
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 district = (property.location.district ?? '').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 = 80
|
||
explanation = 'Kein Standortwunsch — flexibel bewertet'
|
||
} else if (district && preferred.some(p => {
|
||
// "Zürich Kreis 5".includes("Kreis 5") or pSuffix "kreis 5" ⊂ district
|
||
if (p.includes(district)) return true
|
||
const pSuffix = p.replace(/^zürich\s+/i, '').trim()
|
||
// Only use pSuffix when the regex actually removed a prefix (avoids 'zürich' falsely
|
||
// matching district names like 'Zürich-West' because 'Zürich-West'.includes('zürich'))
|
||
return pSuffix !== p && pSuffix.length > 0 && district.includes(pSuffix)
|
||
})) {
|
||
score = 100
|
||
explanation = `Bezirk ${property.location.district} entspricht Präferenz`
|
||
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
|
||
// City matches but check if preferred has district-specific entries for this city
|
||
// → penalise when a specific district was requested but this property is in a different one
|
||
const hasDistrictSpecifics = preferred.some(p => p.includes(city) && p.length > city.length + 2)
|
||
score = hasDistrictSpecifics ? 70 : 100
|
||
explanation = score === 100
|
||
? `Standort ${property.location.city} entspricht Präferenz`
|
||
: `${property.location.city}${property.location.district ? ` (${property.location.district})` : ''} — Lage akzeptiert, bevorzugter Bezirk nicht erfüllt`
|
||
} 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': {
|
||
if (sf?.footfallScore !== undefined) return sf.footfallScore
|
||
// Map passerbyFrequency string (RETAIL properties) to numeric score
|
||
const pfMap: Record<string, number> = { HIGH: 85, MEDIUM_HIGH: 68, MEDIUM: 50, LOW: 30 }
|
||
const pf = (sf as { passerbyFrequency?: string } | undefined)?.passerbyFrequency
|
||
return pf !== undefined ? (pfMap[pf] ?? 50) : undefined
|
||
}
|
||
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) {
|
||
// 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<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 — Schätzung basierend auf Standort (${est.label})`,
|
||
estimated: true,
|
||
}
|
||
}
|
||
return {
|
||
criterion: key,
|
||
weight,
|
||
score: 50,
|
||
contribution: 50 * weight,
|
||
explanation: `${key}: keine Daten verfügbar — neutral bewertet`,
|
||
}
|
||
}
|
||
|
||
// Support both 0–1 float scale (enrichment estimates) and 0–100 integer scale (mock data)
|
||
const score = rawValue > 1
|
||
? Math.round(Math.min(100, Math.max(0, rawValue)))
|
||
: 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`,
|
||
}
|
||
}
|
||
|
||
// ── Structured Requirement Scorers ───────────────────────────────────────────
|
||
|
||
const FIT_OUT_LEVELS: Record<string, number> = { SHELL: 0, BASIC: 1, FULL: 2, PREMIUM: 3 }
|
||
|
||
function scoreGroundFloor(need: Need, property: Property): ScoreFactor | null {
|
||
if (!need.requireGroundFloor) return null
|
||
const floor = property.hardFacts?.floor ?? property.floorLevel
|
||
if (floor === undefined) {
|
||
return { criterion: 'groundFloor', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Erdgeschoss erforderlich — Stockwerk nicht dokumentiert', estimated: true }
|
||
}
|
||
const passed = floor === 0
|
||
const score = passed ? 100 : 20
|
||
return {
|
||
criterion: 'groundFloor',
|
||
weight: 0.05,
|
||
score,
|
||
contribution: score * 0.05,
|
||
explanation: passed ? `Erdgeschoss bestätigt (Etage ${floor})` : `Nicht Erdgeschoss (Etage ${floor}) — EG erforderlich`,
|
||
}
|
||
}
|
||
|
||
function scoreParkingMin(need: Need, property: Property): ScoreFactor | null {
|
||
const min = need.requiredParkingMin
|
||
if (!min || min <= 0) return null
|
||
const available = property.hardFacts?.parking ?? property.softFactors?.parkingSpots ?? 0
|
||
if (available === 0 && property.hardFacts?.parking === undefined) {
|
||
return { criterion: 'parkingMin', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min} Parkplätze erforderlich — keine Daten`, estimated: true }
|
||
}
|
||
const ratio = Math.min(1, available / min)
|
||
const score = available >= min ? 100 : Math.round(ratio * 60)
|
||
return {
|
||
criterion: 'parkingMin',
|
||
weight: 0.04,
|
||
score,
|
||
contribution: score * 0.04,
|
||
explanation: available >= min
|
||
? `${available} Parkplätze vorhanden (mind. ${min} erforderlich)`
|
||
: `Nur ${available} Parkplätze (mind. ${min} erforderlich)`,
|
||
}
|
||
}
|
||
|
||
function scoreAirConditioning(need: Need, property: Property): ScoreFactor | null {
|
||
if (!need.requireAirConditioning) return null
|
||
const hasAC = property.hardFacts?.hasAirConditioning
|
||
if (hasAC === undefined) {
|
||
return { criterion: 'airConditioning', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Klimaanlage erforderlich — nicht dokumentiert', estimated: true }
|
||
}
|
||
const score = hasAC ? 100 : 15
|
||
return {
|
||
criterion: 'airConditioning',
|
||
weight: 0.03,
|
||
score,
|
||
contribution: score * 0.03,
|
||
explanation: hasAC ? 'Klimaanlage vorhanden' : 'Keine Klimaanlage — Klimaanlage erforderlich',
|
||
}
|
||
}
|
||
|
||
function scoreLoadingDock(need: Need, property: Property): ScoreFactor | null {
|
||
if (!need.requireLoadingDock) return null
|
||
const docks = property.hardFacts?.loadingDocksCount
|
||
if (docks === undefined) {
|
||
return { criterion: 'loadingDock', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Laderampe erforderlich — keine Daten', estimated: true }
|
||
}
|
||
const passed = docks > 0
|
||
const score = passed ? 100 : 15
|
||
return {
|
||
criterion: 'loadingDock',
|
||
weight: 0.05,
|
||
score,
|
||
contribution: score * 0.05,
|
||
explanation: passed ? `${docks} Laderampe(n) vorhanden` : 'Keine Laderampe vorhanden — erforderlich',
|
||
}
|
||
}
|
||
|
||
function scoreBarrierFree(need: Need, property: Property): ScoreFactor | null {
|
||
if (!need.requireBarrierFree) return null
|
||
const ok = property.hardFacts?.isBarrierFree
|
||
if (ok === undefined) {
|
||
return { criterion: 'barrierFree', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Barrierefreiheit erforderlich — nicht dokumentiert', estimated: true }
|
||
}
|
||
const score = ok ? 100 : 20
|
||
return {
|
||
criterion: 'barrierFree',
|
||
weight: 0.03,
|
||
score,
|
||
contribution: score * 0.03,
|
||
explanation: ok ? 'Barrierefrei bestätigt' : 'Nicht barrierefrei — Barrierefreiheit erforderlich',
|
||
}
|
||
}
|
||
|
||
function scoreCeilingHeight(need: Need, property: Property): ScoreFactor | null {
|
||
const min = need.minCeilingHeightM
|
||
if (!min || min <= 0) return null
|
||
const actual = property.hardFacts?.ceilingHeightM
|
||
if (actual === undefined) {
|
||
return { criterion: 'ceilingHeight', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min}m Deckenhöhe erforderlich — keine Daten`, estimated: true }
|
||
}
|
||
const passed = actual >= min
|
||
const score = passed ? 100 : Math.max(10, Math.round((actual / min) * 70))
|
||
return {
|
||
criterion: 'ceilingHeight',
|
||
weight: 0.04,
|
||
score,
|
||
contribution: score * 0.04,
|
||
explanation: passed
|
||
? `Deckenhöhe ${actual}m ≥ Minimum ${min}m`
|
||
: `Deckenhöhe ${actual}m unter Minimum ${min}m`,
|
||
}
|
||
}
|
||
|
||
function scoreMinContractDuration(need: Need, property: Property): ScoreFactor | null {
|
||
const min = need.minContractDurationMonths
|
||
if (!min || min <= 0) return null
|
||
const available = property.contractDurationMonths
|
||
if (available === undefined) {
|
||
return { criterion: 'contractDuration', weight: 0.03, score: 50, contribution: 1.5, explanation: `Mind. ${Math.round(min / 12)} Jahre Laufzeit gewünscht — keine Daten`, estimated: true }
|
||
}
|
||
const passed = available >= min
|
||
const score = passed ? 100 : Math.max(20, Math.round((available / min) * 70))
|
||
return {
|
||
criterion: 'contractDuration',
|
||
weight: 0.03,
|
||
score,
|
||
contribution: score * 0.03,
|
||
explanation: passed
|
||
? `${Math.round(available / 12)} Jahre Vertragslaufzeit — erfüllt (mind. ${Math.round(min / 12)} Jahre gewünscht)`
|
||
: `Nur ${Math.round(available / 12)} Jahre — mind. ${Math.round(min / 12)} Jahre gewünscht`,
|
||
}
|
||
}
|
||
|
||
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
|
||
if (!need.requiredFitOut) return null
|
||
const propFitOut = property.hardFacts?.fitOut
|
||
if (!propFitOut) {
|
||
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
|
||
}
|
||
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
|
||
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
|
||
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||
return {
|
||
criterion: 'fitOut',
|
||
weight: 0.04,
|
||
score,
|
||
contribution: score * 0.04,
|
||
explanation: propLevel >= reqLevel
|
||
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
|
||
: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
|
||
}
|
||
}
|
||
|
||
// ── 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.MAISON_WORK) {
|
||
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
||
} 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 coreHardFactors: ScoreFactor[] = [
|
||
scoreArea(need, property, profile.area),
|
||
scoreLocation(need, property, profile.location),
|
||
scoreBudget(need, property, profile.budget),
|
||
scoreTiming(need, property, profile.timing),
|
||
]
|
||
|
||
// Structured requirement scorers — optional hard factors
|
||
const structuredFactors: ScoreFactor[] = [
|
||
scoreGroundFloor(need, property),
|
||
scoreParkingMin(need, property),
|
||
scoreAirConditioning(need, property),
|
||
scoreLoadingDock(need, property),
|
||
scoreBarrierFree(need, property),
|
||
scoreCeilingHeight(need, property),
|
||
scoreMinContractDuration(need, property),
|
||
scoreFitOut(need, property),
|
||
].filter((f): f is ScoreFactor => f !== null)
|
||
|
||
const hardFactors = [...coreHardFactors, ...structuredFactors]
|
||
const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0)
|
||
+ structuredFactors.reduce((s, f) => s + f.weight, 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 ────────────────────────────────────────────────────
|
||
// All 9 factors always included in output so breakdown is always complete.
|
||
// Only weighted factors (weight > 0) contribute to the score calculation.
|
||
const allSoftDisplay: ScoreFactor[] = SOFT_FACTOR_KEYS
|
||
.map(k => scoreSoftFactor(k, profile[k] ?? 0, property))
|
||
const softWeightSum = SOFT_FACTOR_KEYS.reduce((s, k) => s + (profile[k] ?? 0), 0)
|
||
const softRaw = allSoftDisplay.filter(f => f.weight > 0).reduce((s, f) => s + f.contribution, 0)
|
||
const softFactorScore = softWeightSum > 0 ? Math.min(100, Math.round(softRaw / softWeightSum)) : 50
|
||
|
||
// ── Must-have criteria evaluation ─────────────────────────────────────────
|
||
const allMustHaveText = [
|
||
...(need.mustCriteriaText ?? []),
|
||
...(need.mustHaveCriteria?.map(c => c.criterion) ?? []),
|
||
]
|
||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||
|
||
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
|
||
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
|
||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||
|
||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||
const weightedSoftFactors = allSoftDisplay.filter(f => f.weight > 0)
|
||
const allFactors = [...hardFactors, ...weightedSoftFactors]
|
||
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, weightedSoftFactors, 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: 0,
|
||
confidenceModifier: 0,
|
||
positiveFactors,
|
||
negativeFactors,
|
||
allHardFactors: hardFactors,
|
||
allSoftFactors: allSoftDisplay,
|
||
tradeOffs,
|
||
risks,
|
||
missingData,
|
||
mustHaveEvaluation: mustHaveEval.results.length > 0 ? mustHaveEval.results : undefined,
|
||
nextBestActions: [], // filled by rankingEngine
|
||
}
|
||
|
||
output.nextBestActions = generateNextBestActions(output, property, need)
|
||
return output
|
||
}
|