feat: unit-level matching architecture — match against Einheiten, not Objekte
- domain/property: add getEffectiveUnits() — returns explicit units or synthesises one from property-level data so all properties work uniformly at unit level - domain/match: add unitId? field — every Match references a specific unit - domain/unifiedResult: add unit? to VerifiedPortfolioResult + ExternalMarketResult - MockupNeedProvider: generateSyntheticMatches iterates getEffectiveUnits(prop); computeScore uses unit-level areaSqm + rentPricePerSqm; resultId for PRE-MARKET signals matches useSchattenmarktSignals signal ID format - useUnifiedResults: resolve unit from match.unitId for VERIFIED_PORTFOLIO/EXTERNAL_MARKET - matchCardAdapter: unit now extracted for all result types; unitId from match.unitId - IntelligenceMatchCard: regular cards show unit chip (floor + label + m²) when a named unit is known — only adds context, never shows for synthetic/whole-property units Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import { matchStore } from './MockupMatchProvider'
|
||||
import { propertyStore } from './MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import type { PropertyUnit } from '../domain/property'
|
||||
|
||||
const store: Need[] = [...mockNeeds]
|
||||
|
||||
@@ -37,39 +39,42 @@ function locationScore(propCity: string, preferredLocations: string[]): number {
|
||||
|
||||
function computeScore(
|
||||
prop: {
|
||||
assetType: string; areaSqm: number; rentPricePerSqm: number;
|
||||
location: { city: string }; resultType?: string;
|
||||
schattenmarktRelease?: { enabled?: boolean }
|
||||
assetType: string
|
||||
location: { city: string }
|
||||
resultType?: string
|
||||
rentPricePerSqm: number
|
||||
},
|
||||
unit: PropertyUnit,
|
||||
need: Need,
|
||||
): number | null {
|
||||
if (need.assetType && prop.assetType !== need.assetType) return null
|
||||
|
||||
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
|
||||
// Location dominates: same city → 50-90 base, different → 25-45
|
||||
// Location dominates: same city → 65 base, same canton → 42, other → 28
|
||||
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||
|
||||
// Area overlap (+0-20)
|
||||
if (need.requiredArea && prop.areaSqm) {
|
||||
// Area — use unit area (more precise than property aggregate)
|
||||
if (need.requiredArea && unit.areaSqm) {
|
||||
const { min, max } = need.requiredArea
|
||||
if (prop.areaSqm >= min && prop.areaSqm <= max) score += 20
|
||||
else if (prop.areaSqm >= min * 0.7 && prop.areaSqm <= max * 1.5) score += 10
|
||||
else if (prop.areaSqm < min * 0.5 || prop.areaSqm > max * 2) score -= 10
|
||||
if (unit.areaSqm >= min && unit.areaSqm <= max) score += 20
|
||||
else if (unit.areaSqm >= min * 0.7 && unit.areaSqm <= max * 1.5) score += 10
|
||||
else if (unit.areaSqm < min * 0.5 || unit.areaSqm > max * 2) score -= 10
|
||||
}
|
||||
|
||||
// Budget fit (+0-10): property stores annual CHF/m², need budget is monthly CHF/m²
|
||||
if (need.budgetRange?.maxPerSqm && prop.rentPricePerSqm) {
|
||||
const monthlyRate = prop.rentPricePerSqm / 12
|
||||
// Budget — prefer unit-level rent, fall back to property rent; both stored as annual CHF/m²
|
||||
const effectiveRent = unit.rentPricePerSqm ?? prop.rentPricePerSqm
|
||||
if (need.budgetRange?.maxPerSqm && effectiveRent) {
|
||||
const monthlyRate = effectiveRent / 12
|
||||
if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10
|
||||
else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
||||
else score -= 8
|
||||
}
|
||||
|
||||
// Discount for probabilistic/future signals so they score below confirmed listings
|
||||
// Discount: external future signals score lower (probabilistic), pre-market units slightly lower
|
||||
if (prop.resultType === 'FUTURE_AVAILABILITY') {
|
||||
score = Math.round(score * 0.82)
|
||||
} else if (prop.schattenmarktRelease?.enabled) {
|
||||
} else if (unit.schattenmarktRelease?.enabled) {
|
||||
score = Math.round(score * 0.92)
|
||||
}
|
||||
|
||||
@@ -89,56 +94,68 @@ function generateSyntheticMatches(need: Need) {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const score = computeScore(prop, need)
|
||||
if (score === null || score < 25) continue
|
||||
for (const unit of getEffectiveUnits(prop)) {
|
||||
const score = computeScore(prop, unit, need)
|
||||
if (score === null || score < 25) continue
|
||||
|
||||
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
const isGoodLoc = locS >= 0.9
|
||||
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
const isGoodLoc = locS >= 0.9
|
||||
|
||||
// VERIFIED_PORTFOLIO properties with schattenmarktRelease appear as FUTURE_AVAILABILITY
|
||||
// in demand search so demand users see the PRE-MARKET VERIFIED card
|
||||
const effectiveResultType =
|
||||
prop.resultType === 'VERIFIED_PORTFOLIO' && prop.schattenmarktRelease?.enabled
|
||||
// Unit with pre-market release → FUTURE_AVAILABILITY so demand users see PRE-MARKET VERIFIED card
|
||||
// External future signals (property-level) → FUTURE_AVAILABILITY
|
||||
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY'
|
||||
const effectiveResultType = (isPreMarket || isFutureProp)
|
||||
? 'FUTURE_AVAILABILITY'
|
||||
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
|
||||
|
||||
const match: Match = {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
needId: need.id,
|
||||
resultId: prop.id,
|
||||
resultType: effectiveResultType as Match['resultType'],
|
||||
matchScore: score,
|
||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||
status: score >= 75 ? MatchStatus.PENDING_REVIEW : MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: score + 5,
|
||||
softFactorScore: score - 5,
|
||||
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
|
||||
dataQualityModifier: 0.92,
|
||||
totalScore: score,
|
||||
},
|
||||
positiveFactors: isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
||||
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${prop.areaSqm} m² verfügbar` }],
|
||||
negativeFactors: !isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||
: [],
|
||||
tradeoffs: !isGoodLoc
|
||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||
: [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Objekt entspricht den Kernkriterien.`
|
||||
: `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,
|
||||
}
|
||||
// Signal ID must match what useSchattenmarktSignals generates:
|
||||
// unit-level → schattenmarkt-{propId}-{unitId}
|
||||
// property-level fallback (unit.id === prop.id) → schattenmarkt-{propId}
|
||||
const resultId = isPreMarket
|
||||
? (unit.id === prop.id ? `schattenmarkt-${prop.id}` : `schattenmarkt-${prop.id}-${unit.id}`)
|
||||
: prop.id
|
||||
|
||||
matchStore.push(match)
|
||||
const unitAreaLabel = `${unit.areaSqm.toLocaleString('de-CH')} m²`
|
||||
const match: Match = {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
unitId: unit.id,
|
||||
needId: need.id,
|
||||
resultId,
|
||||
resultType: effectiveResultType as Match['resultType'],
|
||||
matchScore: score,
|
||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: score + 5,
|
||||
softFactorScore: score - 5,
|
||||
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
|
||||
dataQualityModifier: 0.92,
|
||||
totalScore: score,
|
||||
},
|
||||
positiveFactors: isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
||||
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${unitAreaLabel} verfügbar` }],
|
||||
negativeFactors: !isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||
: [],
|
||||
tradeoffs: !isGoodLoc
|
||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||
: [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Einheit (${unitAreaLabel}) entspricht den Kernkriterien.`
|
||||
: `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,
|
||||
}
|
||||
|
||||
matchStore.push(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user