/** * Coordinates match generation between NeedProvider, PropertyProvider, and * MatchProvider. Lives in the service layer so no provider needs to import * another provider directly. */ import { matchStore } from '../provider/MockupMatchProvider' import { propertyStore } from '../mock-data/propertyStore' import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums' import type { Match } from '../domain/match' import type { MatchEngineOutput } from '../domain/scoring' import type { Need } from '../domain/need' import type { Property } from '../domain/property' import { getEffectiveUnits } from '../domain/property' import { calculateScore } from '../features/matching/scoreCalculator' import { resolveUnitFacts } from '../lib/unitFacts' function strengthFromScore(s: number): MatchStrength { if (s >= 75) return MatchStrength.STRONG if (s >= 55) return MatchStrength.MODERATE return MatchStrength.WEAK } function buildMatch( prop: Property, unitId: string | undefined, need: Need, output: MatchEngineOutput, effectiveResultType: string, resultId: string, now: string, ): Match { const locationFactor = output.allHardFactors.find(f => f.criterion === 'location') const isGoodLoc = (locationFactor?.score ?? 0) >= 70 return { id: `m__${prop.id}__${unitId ?? 'prop'}__${need.id}`, propertyId: prop.id, unitId, needId: need.id, resultId, resultType: effectiveResultType as Match['resultType'], matchScore: output.finalScore, matchStrength: strengthFromScore(output.finalScore), status: MatchStatus.PENDING_REVIEW, scoreBreakdown: { hardMatchScore: output.hardMatchScore, softFactorScore: output.softFactorScore, confidenceModifier: output.confidenceModifier, dataQualityModifier: output.dataQualityModifier, totalScore: output.finalScore, }, positiveFactors: output.positiveFactors, negativeFactors: output.negativeFactors, allFactors: [...output.allHardFactors, ...output.allSoftFactors], mustHaveEvaluation: output.mustHaveEvaluation, tradeoffs: output.tradeOffs ?? [], explainabilitySummary: isGoodLoc ? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.` : `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`, confidenceLevel: isGoodLoc ? 0.88 : 0.60, riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM, uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'], organizationId: 'org-wincasa', createdAt: now, updatedAt: now, } } function scoreProperty( need: Need, prop: Property, overrideArea?: number, overridePrice?: number, overrideResultType?: string, overrideHardFacts?: Partial>, ): MatchEngineOutput { if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) { return calculateScore(need, { ...prop, areaSqm: overrideArea ?? prop.areaSqm, rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm, resultType: (overrideResultType ?? prop.resultType) as ResultType, // Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts) hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts, }) } return calculateScore(need, prop) } // Externe Inserate aus Drittquellen (Scrapes) sind keine Plattform-Inventar-Objekte und // gehören nicht in die Treffer (nur eigene Plattform-Objekte, Maison Work, Future Availability). const EXTERNAL_SCRAPE_SOURCES = new Set(['HOMEGATE_SCRAPE', 'IMMOSCOUT_SCRAPE', 'NEWHOME_SCRAPE', 'MATCHOFFICE_SCRAPE']) function isExternalListing(prop: Property): boolean { return prop.resultType === ResultType.VERIFIED_PORTFOLIO && !!prop.sourceType && EXTERNAL_SCRAPE_SOURCES.has(prop.sourceType) } /** Generate matches for a single need against all properties and push them into matchStore. */ export function generateMatchesForNeed(need: Need): void { const now = new Date().toISOString() const MIN_SCORE = 22 for (const prop of propertyStore) { // Externe Scrape-Inserate, die fälschlich als Plattform-Objekt getaggt sind → nicht matchen if (isExternalListing(prop)) continue const hasExplicitUnits = (prop.units ?? []).length > 0 if (hasExplicitUnits) { const allPreMarket = prop.units!.every(u => u.schattenmarktRelease?.enabled) if (!allPreMarket) { const output = scoreProperty(need, prop) if (!output.excluded && output.finalScore >= MIN_SCORE) { matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now)) } } for (const unit of prop.units!) { if (!unit.schattenmarktRelease?.enabled) continue const facts = resolveUnitFacts(prop, unit) // Pre-Market: erwarteter künftiger Preis hat Vorrang vor der heutigen Sollmiete const unitPrice = unit.expectedRentPerSqm ?? unit.rentPricePerSqm ?? prop.rentPricePerSqm const unitOutput = scoreProperty(need, prop, unit.areaSqm, unitPrice, ResultType.FUTURE_AVAILABILITY, { fitOut: facts.fitOut, mieterausbaubeitragPerSqm: facts.mabPerSqm, fitOutByLandlord: facts.fitOutByLandlord, parking: facts.parkingSpots, }) if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue const resultId = `schattenmarkt-${prop.id}-${unit.id}` matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now)) } } else { for (const unit of getEffectiveUnits(prop)) { const isPreMarket = unit.schattenmarktRelease?.enabled === true const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY const effectiveResultType = (isPreMarket || isFutureProp) ? ResultType.FUTURE_AVAILABILITY : (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO) const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType) if (output.excluded || output.finalScore < MIN_SCORE) continue const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now)) } } } } /** Remove all existing matches for a need and regenerate them (used after a need update). */ export function syncMatchesForNeed(need: Need): void { for (let i = matchStore.length - 1; i >= 0; i--) { if (matchStore[i].needId === need.id) matchStore.splice(i, 1) } generateMatchesForNeed(need) }