feat: F009 matching engine V1 — deterministic multi-criteria scoring
domain/scoring.ts: HARD_FILTER thresholds, DATA_QUALITY/CONFIDENCE modifier tables, ScoringWeightProfile type, DEFAULT_SCORING_PROFILES for Office/Retail/Light Industrial/Logistics/Production/Default (all sum to 1.00), HARD/SOFT_CRITERION_KEYS, MatchEngineOutput type. features/matching/scoreCalculator.ts: applyHardFilters (asset type mismatch, area < 85% min, budget > 150%, excluded region → hard exclude; occupied → severe penalty), scoreArea/Location/Budget/Timing as ScoreFactor, scoreSoftFactor for 9 keys mapped to property.softFactors, calcDataQualityModifier/calcConfidenceModifier, calculateScore (resolves profile from need.weightingProfile + default, runs filters, computes normalized hard/soft scores, applies modifiers, classifies positive/negative factors, assembles MatchEngineOutput). features/matching/tradeOffAnalyzer.ts: analyzeTradeOffs (6 patterns: location-vs-budget, area-vs-budget, timing-vs-dataQuality, prestige-vs-flexibility, futureSignal-vs-location, accessibility-vs-commute), analyzeRisks (future signal, data quality, budget, occupied, low confidence, missing critical fields), identifyMissingData (rentPricePerSqm, availabilityDate, softFactors, hardFacts, need.budgetRange). features/matching/rankingEngine.ts: matchStrengthFromScore (>=78 STRONG, >=52 MODERATE, else WEAK), generateNextBestActions (score-based, future signal SCHEDULE, missing data VERIFY), buildFullMatch → full Match entity with ScoreBreakdown + explainabilitySummary + uncertaintyIndicators, rankMatches (score desc → resultType order → confidence desc), computeRankedMatches batch helper. services/matchService.ts: computeMatch(need, property) and computeMatchesForNeed(needId) wired to engine. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, ScoringWeightProfile> = {
|
||||
// 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[]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<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.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ListResponse<Match>> {
|
||||
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<StrongMatchItem[]> {
|
||||
const [matches, properties] = await Promise.all([
|
||||
provider.getAll(),
|
||||
|
||||
Reference in New Issue
Block a user