fix: property-level scoring for multi-unit properties + multi-unit chip
For properties with explicit sub-units (e.g. prop-001: 280/310/260 m²), synthetic matching now generates ONE property-level match using the aggregate areaSqm (850 m²) instead of per-unit matches — ensuring a need for 800-1000 m² correctly scores the whole floor as +20 (STRONG) rather than -10 each unit. Pre-market unit-level matches are still generated per released unit so tenants seeking smaller spaces still see the specific unit signals. Card shows "X Einheiten · total Y m²" chip when a multi-unit property is matched at property level (no specific unit assigned). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -399,7 +399,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Unit context — only shown when a specific named unit is known */}
|
{/* Specific named unit */}
|
||||||
{vm.preMarketUnit?.unitLabel && (
|
{vm.preMarketUnit?.unitLabel && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -413,6 +413,20 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Multi-unit property: no specific unit matched — show aggregate summary */}
|
||||||
|
{!vm.preMarketUnit && (vm.preMarketAllUnits?.length ?? 0) > 1 && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${vm.preMarketAllUnits!.length} Einheiten`}
|
||||||
|
sx={{ height: 18, fontSize: '0.62rem', bgcolor: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem' }}>
|
||||||
|
total {vm.preMarketAllUnits!.reduce((s, u) => s + u.areaSqm, 0).toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Regular explainability summary */}
|
{/* Regular explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { propertyStore } from './MockupPropertyProvider'
|
|||||||
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
||||||
import type { Match } from '../domain/match'
|
import type { Match } from '../domain/match'
|
||||||
import { getEffectiveUnits } from '../domain/property'
|
import { getEffectiveUnits } from '../domain/property'
|
||||||
import type { PropertyUnit } from '../domain/property'
|
|
||||||
|
|
||||||
const store: Need[] = [...mockNeeds]
|
const store: Need[] = [...mockNeeds]
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ function locationScore(propCity: string, preferredLocations: string[]): number {
|
|||||||
const p = pref.toLowerCase()
|
const p = pref.toLowerCase()
|
||||||
if (pc.includes(p) || p.includes(pc)) return 1.0
|
if (pc.includes(p) || p.includes(pc)) return 1.0
|
||||||
}
|
}
|
||||||
// Same canton check
|
|
||||||
const propCanton = CANTON_MAP[pc]
|
const propCanton = CANTON_MAP[pc]
|
||||||
if (propCanton) {
|
if (propCanton) {
|
||||||
for (const pref of preferredLocations) {
|
for (const pref of preferredLocations) {
|
||||||
@@ -42,45 +40,38 @@ function computeScore(
|
|||||||
assetType: string
|
assetType: string
|
||||||
location: { city: string }
|
location: { city: string }
|
||||||
resultType?: string
|
resultType?: string
|
||||||
rentPricePerSqm: number
|
|
||||||
},
|
},
|
||||||
unit: PropertyUnit,
|
|
||||||
need: Need,
|
need: Need,
|
||||||
|
areaSqm: number,
|
||||||
|
rentPricePerSqm: number | undefined,
|
||||||
|
isPreMarket = false,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (need.assetType && prop.assetType !== need.assetType) return null
|
if (need.assetType && prop.assetType !== need.assetType) return null
|
||||||
|
|
||||||
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||||
|
|
||||||
// Location dominates: same city → 65 base, same canton → 42, other → 28
|
|
||||||
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||||
|
|
||||||
// Area — use unit area (more precise than property aggregate)
|
if (need.requiredArea && areaSqm) {
|
||||||
if (need.requiredArea && unit.areaSqm) {
|
|
||||||
const { min, max } = need.requiredArea
|
const { min, max } = need.requiredArea
|
||||||
if (unit.areaSqm >= min && unit.areaSqm <= max) score += 20
|
if (areaSqm >= min && areaSqm <= max) score += 20
|
||||||
else if (unit.areaSqm >= min * 0.7 && unit.areaSqm <= max * 1.5) score += 10
|
else if (areaSqm >= min * 0.7 && areaSqm <= max * 1.5) score += 10
|
||||||
else if (unit.areaSqm < min * 0.5 || unit.areaSqm > max * 2) score -= 10
|
else if (areaSqm < min * 0.5 || areaSqm > max * 2) score -= 10
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget — prefer unit-level rent, fall back to property rent; both stored as annual CHF/m²
|
if (need.budgetRange?.maxPerSqm && rentPricePerSqm) {
|
||||||
const effectiveRent = unit.rentPricePerSqm ?? prop.rentPricePerSqm
|
const monthlyRate = rentPricePerSqm / 12
|
||||||
if (need.budgetRange?.maxPerSqm && effectiveRent) {
|
|
||||||
const monthlyRate = effectiveRent / 12
|
|
||||||
if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10
|
if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10
|
||||||
else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
||||||
else score -= 8
|
else score -= 8
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discount: external future signals score lower (probabilistic), pre-market units slightly lower
|
|
||||||
if (prop.resultType === 'FUTURE_AVAILABILITY') {
|
if (prop.resultType === 'FUTURE_AVAILABILITY') {
|
||||||
score = Math.round(score * 0.82)
|
score = Math.round(score * 0.82)
|
||||||
} else if (unit.schattenmarktRelease?.enabled) {
|
} else if (isPreMarket) {
|
||||||
score = Math.round(score * 0.92)
|
score = Math.round(score * 0.92)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small jitter so results look natural
|
|
||||||
score += Math.floor(Math.random() * 6) - 2
|
score += Math.floor(Math.random() * 6) - 2
|
||||||
|
|
||||||
return Math.min(97, Math.max(22, score))
|
return Math.min(97, Math.max(22, score))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,71 +81,106 @@ function strengthFromScore(s: number): string {
|
|||||||
return MatchStrength.WEAK
|
return MatchStrength.WEAK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMatch(
|
||||||
|
prop: typeof propertyStore[0],
|
||||||
|
unitId: string | undefined,
|
||||||
|
need: Need,
|
||||||
|
score: number,
|
||||||
|
effectiveResultType: string,
|
||||||
|
resultId: string,
|
||||||
|
isGoodLoc: boolean,
|
||||||
|
areaLabel: string,
|
||||||
|
now: string,
|
||||||
|
): Match {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
propertyId: prop.id,
|
||||||
|
unitId,
|
||||||
|
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: `${areaLabel} 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. ${areaLabel} 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateSyntheticMatches(need: Need) {
|
function generateSyntheticMatches(need: Need) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
for (const prop of propertyStore) {
|
for (const prop of propertyStore) {
|
||||||
for (const unit of getEffectiveUnits(prop)) {
|
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||||
const score = computeScore(prop, unit, need)
|
|
||||||
if (score === null || score < 25) continue
|
|
||||||
|
|
||||||
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
if (hasExplicitUnits) {
|
||||||
const isGoodLoc = locS >= 0.9
|
// Multi-unit property: score against aggregate area (tenants renting the whole floor/building)
|
||||||
|
const propScore = computeScore(prop, need, prop.areaSqm, prop.rentPricePerSqm)
|
||||||
// Unit with pre-market release → FUTURE_AVAILABILITY so demand users see PRE-MARKET VERIFIED card
|
if (propScore !== null && propScore >= 25) {
|
||||||
// External future signals (property-level) → FUTURE_AVAILABILITY
|
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||||
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
matchStore.push(buildMatch(
|
||||||
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY'
|
prop, undefined, need, propScore,
|
||||||
const effectiveResultType = (isPreMarket || isFutureProp)
|
prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id,
|
||||||
? 'FUTURE_AVAILABILITY'
|
isGoodLoc, `${prop.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||||
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
|
))
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
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)
|
// Unit-level pre-market: generate per released unit (for tenants seeking that specific unit size)
|
||||||
|
for (const unit of prop.units!) {
|
||||||
|
if (!unit.schattenmarktRelease?.enabled) continue
|
||||||
|
const unitScore = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, true)
|
||||||
|
if (unitScore === null || unitScore < 25) continue
|
||||||
|
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||||
|
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||||
|
matchStore.push(buildMatch(
|
||||||
|
prop, unit.id, need, unitScore,
|
||||||
|
'FUTURE_AVAILABILITY', resultId,
|
||||||
|
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No explicit units: use getEffectiveUnits (whole property synthesised as one unit)
|
||||||
|
for (const unit of getEffectiveUnits(prop)) {
|
||||||
|
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||||
|
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
const score = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, isPreMarket)
|
||||||
|
if (score === null || score < 25) continue
|
||||||
|
|
||||||
|
const effectiveResultType = (isPreMarket || isFutureProp)
|
||||||
|
? 'FUTURE_AVAILABILITY'
|
||||||
|
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
|
||||||
|
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
|
||||||
|
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||||
|
|
||||||
|
matchStore.push(buildMatch(
|
||||||
|
prop, undefined, need, score,
|
||||||
|
effectiveResultType, resultId,
|
||||||
|
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user