8cb6f21581
External third-party scrapes (Homegate/ImmoScout/newhome/MatchOffice) were mis-tagged as VERIFIED_PORTFOLIO in mock data and surfaced as "own platform" matches. Skip them in match generation — demand results now only contain genuine platform objects, Maison Work, and Future Availability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
6.6 KiB
TypeScript
155 lines
6.6 KiB
TypeScript
/**
|
|
* 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<NonNullable<Property['hardFacts']>>,
|
|
): 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)
|
|
}
|