diff --git a/src/domain/scoring.ts b/src/domain/scoring.ts new file mode 100644 index 0000000..c2ee43f --- /dev/null +++ b/src/domain/scoring.ts @@ -0,0 +1,143 @@ +import type { ScoreFactor, TradeOff, Risk, MissingDataItem, NextBestAction } from './match' + +// ── Hard Filter Thresholds ──────────────────────────────────────────────────── + +export const HARD_FILTER = { + AREA_MIN_TOLERANCE: 0.85, // exclude if property < 85% of need's min area + AREA_MAX_RATIO: 2.50, // severe penalty if property > 2.5× need's max area + BUDGET_EXCLUSION_RATIO: 1.50, // exclude if rent > 150% of max budget/m² + BUDGET_SEVERE_RATIO: 1.25, // severe penalty if 125–150% over budget + BUDGET_MODERATE_RATIO: 1.10, // mild penalty if 110–125% over budget + TIMING_GRACE_DAYS: 90, // allow ±90 days window flexibility +} as const + +// ── Score Architecture ──────────────────────────────────────────────────────── + +// Within each group, scores are weighted and normalized to 0–100. +// Final = baseScore + dataQualityModifier + confidenceModifier, clamped 0–100. +export const SCORE_SPLIT = { + HARD_CRITERIA: 0.60, // expected contribution from hard criteria group + SOFT_FACTORS: 0.40, // expected contribution from soft factors group +} as const + +export const HARD_CRITERION_KEYS = ['area', 'location', 'budget', 'timing'] as const +export type HardCriterionKey = typeof HARD_CRITERION_KEYS[number] + +export const SOFT_FACTOR_KEYS = [ + 'prestige', 'accessibility', 'expansionPotential', 'flexibility', + 'visibility', 'footfall', 'talentAccess', 'esg', 'taxEnvironment', +] as const +export type SoftFactorKey = typeof SOFT_FACTOR_KEYS[number] + +// ── Modifier Tables ─────────────────────────────────────────────────────────── + +export const DATA_QUALITY_MODIFIER = { + EXCELLENT: +5, // dataQuality.score >= 0.85 + GOOD: 0, // >= 0.70 + FAIR: -5, // >= 0.55 + POOR: -10, // >= 0.40 + CRITICAL: -15, // < 0.40 +} as const + +export const CONFIDENCE_MODIFIER = { + VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80 + VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80 + EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type + FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability + LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above) +} as const + +// ── Scoring Weight Profile ──────────────────────────────────────────────────── + +export interface ScoringWeightProfile { + // Hard criteria + area: number + location: number + budget: number + timing: number + // Soft factors + prestige: number + accessibility: number + expansionPotential: number + flexibility: number + visibility: number + footfall: number + talentAccess: number + esg: number + taxEnvironment: number + [key: string]: number +} + +// ── Default Profiles per Asset Type ────────────────────────────────────────── +// Each profile sums to 1.00. No magic numbers — weights reflect domain logic. + +export const DEFAULT_SCORING_PROFILES: Record = { + // Büro: ÖV-Anbindung, Talent Access, Prestige, ESG stark gewichtet + OFFICE: { + area: 0.18, location: 0.18, budget: 0.15, timing: 0.09, + prestige: 0.07, accessibility: 0.11, expansionPotential: 0.05, + flexibility: 0.05, visibility: 0.02, footfall: 0.01, + talentAccess: 0.07, esg: 0.02, taxEnvironment: 0.00, + }, + // Retail: Frequenz, Sichtbarkeit und Standort dominieren + RETAIL: { + area: 0.10, location: 0.15, budget: 0.13, timing: 0.06, + prestige: 0.04, accessibility: 0.07, expansionPotential: 0.03, + flexibility: 0.08, visibility: 0.14, footfall: 0.18, + talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00, + }, + // Light Industrial: Fläche, Andienung (accessibility), Infrastruktur + LIGHT_INDUSTRIAL: { + area: 0.20, location: 0.12, budget: 0.18, timing: 0.10, + prestige: 0.01, accessibility: 0.14, expansionPotential: 0.07, + flexibility: 0.05, visibility: 0.01, footfall: 0.00, + talentAccess: 0.04, esg: 0.04, taxEnvironment: 0.04, + }, + // Logistik: Autobahnanbindung (accessibility), Andienung, Fläche, Verfügbarkeit + LOGISTICS: { + area: 0.18, location: 0.18, budget: 0.14, timing: 0.13, + prestige: 0.01, accessibility: 0.18, expansionPotential: 0.06, + flexibility: 0.04, visibility: 0.01, footfall: 0.00, + talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03, + }, + PRODUCTION: { + area: 0.22, location: 0.13, budget: 0.18, timing: 0.10, + prestige: 0.01, accessibility: 0.13, expansionPotential: 0.08, + flexibility: 0.04, visibility: 0.01, footfall: 0.00, + talentAccess: 0.04, esg: 0.03, taxEnvironment: 0.03, + }, + DEFAULT: { + area: 0.20, location: 0.18, budget: 0.18, timing: 0.10, + prestige: 0.05, accessibility: 0.09, expansionPotential: 0.05, + flexibility: 0.05, visibility: 0.02, footfall: 0.02, + talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01, + }, +} + +// ── Engine IO Types ─────────────────────────────────────────────────────────── + +export interface HardFilterResult { + excluded: boolean + reason?: string + severePenalty: number // extra points deducted on top of criterion score (0–30) +} + +export interface MatchEngineOutput { + propertyId: string + needId: string + excluded: boolean + excludedReason?: string + finalScore: number // 0–100 clamped + hardMatchScore: number // 0–100 normalized + softFactorScore: number // 0–100 normalized + dataQualityModifier: number + confidenceModifier: number + positiveFactors: ScoreFactor[] + negativeFactors: ScoreFactor[] + allHardFactors: ScoreFactor[] + allSoftFactors: ScoreFactor[] + tradeOffs: TradeOff[] + risks: Risk[] + missingData: MissingDataItem[] + nextBestActions: NextBestAction[] +} diff --git a/src/features/matching/rankingEngine.ts b/src/features/matching/rankingEngine.ts new file mode 100644 index 0000000..cb0f0c0 --- /dev/null +++ b/src/features/matching/rankingEngine.ts @@ -0,0 +1,211 @@ +import type { Need } from '../../domain/need' +import type { Property } from '../../domain/property' +import type { Match, NextBestAction, ScoreBreakdown } from '../../domain/match' +import { MatchStrength, RiskLevel, ResultType, ConfidenceLevel } from '../../domain/enums' +import type { MatchEngineOutput } from '../../domain/scoring' +import { calculateScore } from './scoreCalculator' + +// ── Strength + Risk Classification ─────────────────────────────────────────── + +export function matchStrengthFromScore(score: number): typeof MatchStrength[keyof typeof MatchStrength] { + if (score >= 78) return MatchStrength.STRONG + if (score >= 52) return MatchStrength.MODERATE + return MatchStrength.WEAK +} + +function riskLevelFromRisks(risks: Match['risks']): typeof RiskLevel[keyof typeof RiskLevel] { + if (!risks || risks.length === 0) return RiskLevel.LOW + const levels = risks.map(r => r.level) + if (levels.includes(RiskLevel.CRITICAL)) return RiskLevel.CRITICAL + if (levels.includes(RiskLevel.HIGH)) return RiskLevel.HIGH + if (levels.includes(RiskLevel.MEDIUM)) return RiskLevel.MEDIUM + return RiskLevel.LOW +} + +function confidenceLabelFromScore(score: number): typeof ConfidenceLevel[keyof typeof ConfidenceLevel] { + if (score >= 0.90) return ConfidenceLevel.VERY_HIGH + if (score >= 0.75) return ConfidenceLevel.HIGH + if (score >= 0.55) return ConfidenceLevel.MEDIUM + if (score >= 0.35) return ConfidenceLevel.LOW + return ConfidenceLevel.VERY_LOW +} + +// ── Next Best Action Generator ──────────────────────────────────────────────── + +export function generateNextBestActions( + output: MatchEngineOutput, + property: Property, + _need: Need, +): NextBestAction[] { + const actions: NextBestAction[] = [] + + if (output.excluded) { + actions.push({ + label: 'Kriterien überprüfen', + description: `Ausschlussgrund: ${output.excludedReason}`, + priority: 'HIGH', + actionType: 'REVIEW', + }) + return actions + } + + const score = output.finalScore + + if (score >= 78) { + actions.push({ + label: 'Shortlist hinzufügen', + description: 'Starkes Match — sofort zur Shortlist hinzufügen', + priority: 'HIGH', + actionType: 'SHORTLIST', + }) + actions.push({ + label: 'Besichtigung anfragen', + description: 'Dieses Objekt zeitnah besichtigen', + priority: 'HIGH', + actionType: 'CONTACT', + }) + } else if (score >= 52) { + actions.push({ + label: 'Details verifizieren', + description: 'Mittleres Match — kritische Datenpunkte direkt bestätigen', + priority: 'MEDIUM', + actionType: 'VERIFY', + }) + actions.push({ + label: 'Mit Alternativen vergleichen', + description: 'Dieses Objekt mit anderen Matches vergleichen', + priority: 'MEDIUM', + actionType: 'COMPARE', + }) + } else { + actions.push({ + label: 'Manuell prüfen', + description: 'Schwaches Match — Eignung manuell beurteilen', + priority: 'LOW', + actionType: 'REVIEW', + }) + } + + if (property.resultType === ResultType.FUTURE_AVAILABILITY) { + actions.push({ + label: 'Frühzeitig vormerken', + description: 'Verfügbarkeit unbestätigt — Signal im Auge behalten', + priority: 'MEDIUM', + actionType: 'SCHEDULE', + }) + } + + if (output.missingData.some(m => m.importance === 'CRITICAL' || m.importance === 'HIGH')) { + actions.push({ + label: 'Fehlende Daten anfordern', + description: 'Objektdaten für vollständige Bewertung vervollständigen', + priority: 'HIGH', + actionType: 'VERIFY', + }) + } + + return actions.slice(0, 4) // cap at 4 actions +} + +// ── Build Full Match Entity ─────────────────────────────────────────────────── + +export function buildFullMatch(need: Need, property: Property): Match { + const output = calculateScore(need, property) + const now = new Date().toISOString() + + const scoreBreakdown: ScoreBreakdown = { + hardMatchScore: output.hardMatchScore, + softFactorScore: output.softFactorScore, + confidenceModifier: output.confidenceModifier, + dataQualityModifier: output.dataQualityModifier, + totalScore: output.finalScore, + } + + const matchScore = output.finalScore + const matchStrength = matchStrengthFromScore(matchScore) + const riskLevel = riskLevelFromRisks(output.risks) + const confidenceLevel = property.confidenceScore + + const summary = buildExplainabilitySummary(output, property, matchScore, matchStrength) + + const uncertaintyIndicators: string[] = [] + if (property.resultType === ResultType.FUTURE_AVAILABILITY) uncertaintyIndicators.push('Zukünftiges Signal — nicht bestätigt') + if (property.confidenceScore < 0.60) uncertaintyIndicators.push(`Niedrige Konfidenz (${Math.round(property.confidenceScore * 100)}%)`) + if ((property.dataQuality?.score ?? 1) < 0.55) uncertaintyIndicators.push('Unvollständige Datenbasis') + if (output.missingData.some(m => m.importance === 'CRITICAL')) uncertaintyIndicators.push('Kritische Daten fehlen') + + return { + id: `match-${need.id}-${property.id}`, + needId: need.id, + propertyId: property.id, + resultId: property.id, + resultType: property.resultType, + + matchScore, + matchStrength, + scoreBreakdown, + confidenceLevel, + confidenceLevelLabel: confidenceLabelFromScore(confidenceLevel), + dataConfidenceScore: property.dataQuality?.score, + + positiveFactors: output.positiveFactors, + negativeFactors: output.negativeFactors, + tradeoffs: output.tradeOffs, + tradeOffs: output.tradeOffs, + risks: output.risks, + missingData: output.missingData, + nextBestActions: output.nextBestActions, + explainabilitySummary: summary, + + riskLevel, + uncertaintyIndicators, + + status: undefined, + createdAt: now, + updatedAt: now, + } +} + +function buildExplainabilitySummary( + output: MatchEngineOutput, + property: Property, + score: number, + strength: string, +): string { + if (output.excluded) { + return `Ausgeschlossen: ${output.excludedReason}` + } + const top = output.positiveFactors[0] + const bottom = output.negativeFactors[0] + const futureNote = property.resultType === ResultType.FUTURE_AVAILABILITY + ? ' (Verfügbarkeit unbestätigt)' + : '' + const positive = top ? ` Stärke: ${top.explanation}.` : '' + const negative = bottom ? ` Schwäche: ${bottom.explanation}.` : '' + return `${strength}-Match mit ${score} Punkten${futureNote}.${positive}${negative}` +} + +// ── Ranking ─────────────────────────────────────────────────────────────────── + +export function rankMatches(matches: Match[]): Match[] { + return [...matches].sort((a, b) => { + // Primary: matchScore descending + if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore + // Secondary: VERIFIED > EXTERNAL > FUTURE + const typeOrder = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, FUTURE_AVAILABILITY: 2 } + const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1 + const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1 + if (aOrder !== bOrder) return aOrder - bOrder + // Tertiary: higher confidence first + return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0) + }) +} + +// ── Batch computation ───────────────────────────────────────────────────────── + +export function computeRankedMatches(need: Need, properties: Property[]): Match[] { + const matches = properties + .map(p => buildFullMatch(need, p)) + .filter(m => !m.matchScore || m.matchScore > 0) // exclude hard-filtered + return rankMatches(matches) +} diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts new file mode 100644 index 0000000..18b6b43 --- /dev/null +++ b/src/features/matching/scoreCalculator.ts @@ -0,0 +1,414 @@ +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} 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 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 0–1 scale → convert to 0–100 + const score = Math.round(Math.min(100, Math.max(0, rawValue * 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`, + } +} + +// ── 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.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.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 0–100; 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 +} diff --git a/src/features/matching/tradeOffAnalyzer.ts b/src/features/matching/tradeOffAnalyzer.ts new file mode 100644 index 0000000..fbb9368 --- /dev/null +++ b/src/features/matching/tradeOffAnalyzer.ts @@ -0,0 +1,232 @@ +import type { Need } from '../../domain/need' +import type { Property } from '../../domain/property' +import type { ScoreFactor, TradeOff, Risk, MissingDataItem } from '../../domain/match' +import { ResultType, AvailabilityStatus } from '../../domain/enums' +import { RiskLevel } from '../../domain/enums' + +// ── Trade-Off Detection ─────────────────────────────────────────────────────── + +export function analyzeTradeOffs( + hardFactors: ScoreFactor[], + softFactors: ScoreFactor[], + need: Need, + property: Property, +): TradeOff[] { + const tradeOffs: TradeOff[] = [] + const byKey = (factors: ScoreFactor[], key: string) => factors.find(f => f.criterion === key) + + const location = byKey(hardFactors, 'location') + const budget = byKey(hardFactors, 'budget') + const area = byKey(hardFactors, 'area') + const timing = byKey(hardFactors, 'timing') + const prestige = byKey(softFactors, 'prestige') + const flex = byKey(softFactors, 'flexibility') + const access = byKey(softFactors, 'accessibility') + + // Prime location at budget premium + if (location && budget && location.score >= 85 && budget.score < 60) { + tradeOffs.push({ + criterion: 'location-vs-budget', + concern: `Erstklassiger Standort (${property.location.city}) zu erhöhten Mietkosten`, + severity: budget.score < 40 ? 'HIGH' : 'MEDIUM', + mitigation: 'Nebenkosten analysieren; längere Laufzeit für Konditionenverhandlung nutzen', + impactOnScore: -Math.round((100 - budget.score) * budget.weight * 10), + }) + } + + // Large space but poor budget fit + if (area && budget && area.score >= 80 && budget.score < 55) { + tradeOffs.push({ + criterion: 'area-vs-budget', + concern: 'Grosszügige Fläche übersteigt Budget — Teiluntermiete denkbar', + severity: 'MEDIUM', + mitigation: 'Möglichkeit für Untermiete oder Co-Working prüfen', + impactOnScore: -5, + }) + } + + // Good timing but low data quality + if (timing && timing.score >= 80 && (property.dataQuality?.score ?? 1) < 0.55) { + tradeOffs.push({ + criterion: 'timing-vs-dataQuality', + concern: 'Verfügbarkeit stimmt, Datenbasis ist aber noch unvollständig', + severity: 'MEDIUM', + mitigation: 'Objektdaten vor Zusage direkt beim Vermieter verifizieren', + impactOnScore: -8, + }) + } + + // High prestige but low flexibility + if (prestige && flex && prestige.score >= 75 && flex.score < 40) { + tradeOffs.push({ + criterion: 'prestige-vs-flexibility', + concern: 'Repräsentative Lage mit eingeschränkter Vertragsflexibilität', + severity: 'LOW', + mitigation: 'Breakclause-Option in Verhandlung einfordern', + impactOnScore: -4, + }) + } + + // Future signal with good location + if (property.resultType === ResultType.FUTURE_AVAILABILITY && location && location.score >= 85) { + tradeOffs.push({ + criterion: 'futureSignal-vs-location', + concern: 'Sehr guter Standort, aber Verfügbarkeit noch unbestätigt', + severity: 'HIGH', + mitigation: 'Frühzeitig Kontakt mit Eigentümer aufnehmen; Letter of Intent erwägen', + impactOnScore: -12, + }) + } + + // Good accessibility but poor public transport + if (access && access.score < 40 && need.softFactors?.maxPublicTransportMinutes !== undefined) { + tradeOffs.push({ + criterion: 'accessibility-vs-commute', + concern: 'Erreichbarkeit unter Ihren Anforderungen — Pendlererfahrung beeinträchtigt', + severity: 'MEDIUM', + mitigation: 'Shuttle-Service oder Mobility-Angebot als Kompensation anfragen', + impactOnScore: -6, + }) + } + + return tradeOffs +} + +// ── Risk Analysis ───────────────────────────────────────────────────────────── + +export function analyzeRisks(property: Property, hardFactors: ScoreFactor[]): Risk[] { + const risks: Risk[] = [] + const byKey = (key: string) => hardFactors.find(f => f.criterion === key) + + // Future availability risk — always flag + if (property.resultType === ResultType.FUTURE_AVAILABILITY) { + risks.push({ + category: 'Verfügbarkeit', + description: 'Zukünftiges Signal — Verfügbarkeit ist nicht bestätigt und kann sich verschieben oder entfallen', + level: RiskLevel.HIGH, + mitigation: 'Absichtserklärung einholen; alternative Objekte parallel prüfen', + }) + } + + // Data quality risk + const dq = property.dataQuality?.score ?? 0.5 + if (dq < 0.55) { + risks.push({ + category: 'Datenqualität', + description: `Datenqualität ${Math.round(dq * 100)}% — Angaben unvollständig oder nicht verifiziert`, + level: dq < 0.40 ? RiskLevel.HIGH : RiskLevel.MEDIUM, + mitigation: 'Objektdaten direkt beim Anbieter anfordern und validieren', + }) + } + + // Budget risk + const budgetFactor = byKey('budget') + if (budgetFactor && budgetFactor.score < 50) { + risks.push({ + category: 'Budget', + description: 'Mietpreis liegt über dem gesetzten Budget — finanzielle Belastung prüfen', + level: budgetFactor.score < 30 ? RiskLevel.HIGH : RiskLevel.MEDIUM, + mitigation: 'Vollkostenrechnung inkl. Nebenkosten erstellen; Verhandlungsspielraum ausloten', + }) + } + + // Occupied / delayed availability + if (property.availabilityStatus === AvailabilityStatus.OCCUPIED) { + risks.push({ + category: 'Verfügbarkeit', + description: 'Objekt aktuell belegt — Übergabetermin unsicher', + level: RiskLevel.MEDIUM, + mitigation: 'Verbindlichen Übergabetermin schriftlich vereinbaren', + }) + } + + // Low confidence score + if (property.confidenceScore < 0.50) { + risks.push({ + category: 'Datenverlässlichkeit', + description: `Konfidenz ${Math.round(property.confidenceScore * 100)}% — Quelldaten unsicher`, + level: RiskLevel.MEDIUM, + mitigation: 'Unabhängige Verifikation der Objektangaben empfohlen', + }) + } + + // Missing critical property data + const criticalMissing = property.dataQuality?.missingCriticalFields ?? [] + if (criticalMissing.length > 0) { + risks.push({ + category: 'Fehlende Kerndaten', + description: `Fehlende Pflichtfelder: ${criticalMissing.slice(0, 3).join(', ')}${criticalMissing.length > 3 ? ` +${criticalMissing.length - 3}` : ''}`, + level: RiskLevel.MEDIUM, + mitigation: 'Objektdaten vor Verhandlung vervollständigen lassen', + }) + } + + return risks +} + +// ── Missing Data Detection ──────────────────────────────────────────────────── + +export function identifyMissingData(property: Property, need: Need): MissingDataItem[] { + const missing: MissingDataItem[] = [] + + if (!property.rentPricePerSqm || property.rentPricePerSqm <= 0) { + missing.push({ + field: 'rentPricePerSqm', + importance: 'CRITICAL', + description: 'Mietpreis fehlt — Budget-Scoring nicht möglich', + impact: 'Budget-Score wird neutral (50) gesetzt — Gesamtscore unzuverlässig', + }) + } + + if (!property.availabilityDate || property.availabilityDate === '') { + missing.push({ + field: 'availabilityDate', + importance: 'HIGH', + description: 'Kein Verfügbarkeitsdatum angegeben', + impact: 'Timing-Score reduziert auf 38/100 — Einzugsfenster nicht prüfbar', + }) + } + + if (!property.softFactors) { + missing.push({ + field: 'softFactors', + importance: 'HIGH', + description: 'Soft Factors vollständig fehlend (Prestige, Erreichbarkeit, etc.)', + impact: 'Alle Soft-Factor-Scores auf neutral (50) gesetzt — Matching-Qualität eingeschränkt', + }) + } else { + const sf = property.softFactors + const missingFields: Array<[string, string]> = [] + if (sf.commuterAccessScore === undefined && sf.accessibility === undefined) missingFields.push(['accessibility', 'Erreichbarkeit']) + if (sf.prestigeScore === undefined && sf.prestige === undefined) missingFields.push(['prestige', 'Prestige-Score']) + if (sf.esgScore === undefined) missingFields.push(['esgScore', 'ESG-Bewertung']) + if (missingFields.length > 0) { + missing.push({ + field: missingFields.map(([k]) => k).join(', '), + importance: 'MEDIUM', + description: `Fehlende Soft Factors: ${missingFields.map(([, l]) => l).join(', ')}`, + impact: 'Betroffene Scores neutral — Matching-Präzision verringert', + }) + } + } + + if (!property.hardFacts) { + missing.push({ + field: 'hardFacts', + importance: 'MEDIUM', + description: 'Technische Objektdaten fehlen (Parkierung, ÖV-Score, etc.)', + impact: 'Infrastruktureignung nicht prüfbar', + }) + } + + if (need.budgetRange?.maxPerSqm === undefined || need.budgetRange.maxPerSqm <= 0) { + missing.push({ + field: 'need.budgetRange', + importance: 'HIGH', + description: 'Kein Budget im Bedarf angegeben', + impact: 'Budget-Scoring neutralisiert — Filter unwirksam', + }) + } + + return missing +} diff --git a/src/services/matchService.ts b/src/services/matchService.ts index 597c7e3..44e2305 100644 --- a/src/services/matchService.ts +++ b/src/services/matchService.ts @@ -1,9 +1,13 @@ import { MockupMatchProvider } from '../provider/MockupMatchProvider' import { MockupPropertyProvider } from '../provider/MockupPropertyProvider' +import { MockupNeedProvider } from '../provider/MockupNeedProvider' import type { MatchFilters } from '../provider/IMatchProvider' import type { Match } from '../domain/match' +import type { Need } from '../domain/need' +import type { Property } from '../domain/property' import type { StrongMatchItem } from '../domain/dashboard' import type { ListResponse, ItemResponse } from './types' +import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine' const provider = MockupMatchProvider @@ -34,6 +38,22 @@ export const matchService = { return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } }, + // ── Engine-based methods ────────────────────────────────────────────────── + + computeMatch(need: Need, property: Property): Match { + return buildFullMatch(need, property) + }, + + async computeMatchesForNeed(needId: string): Promise> { + const [need, properties] = await Promise.all([ + MockupNeedProvider.getById(needId), + MockupPropertyProvider.getAll(), + ]) + if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } } + const data = computeRankedMatches(need, properties) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + }, + async getStrongMatches(minScore = 80): Promise { const [matches, properties] = await Promise.all([ provider.getAll(),