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 }