From a39c6ed20085f41a533b263cfc800e8a5ef83a5c Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Thu, 21 May 2026 00:30:03 +0200 Subject: [PATCH] fix: property-level scoring for multi-unit properties + multi-unit chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../match-card/IntelligenceMatchCard.tsx | 16 +- src/provider/MockupNeedProvider.ts | 180 ++++++++++-------- 2 files changed, 118 insertions(+), 78 deletions(-) diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 3d5c437..6b181d1 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -399,7 +399,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro {[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')} - {/* Unit context — only shown when a specific named unit is known */} + {/* Specific named unit */} {vm.preMarketUnit?.unitLabel && ( )} + {/* Multi-unit property: no specific unit matched — show aggregate summary */} + {!vm.preMarketUnit && (vm.preMarketAllUnits?.length ?? 0) > 1 && ( + + + + total {vm.preMarketAllUnits!.reduce((s, u) => s + u.areaSqm, 0).toLocaleString('de-CH')} m² + + + )} + {/* Regular explainability summary */} {vm.explainabilitySummary && ( <> diff --git a/src/provider/MockupNeedProvider.ts b/src/provider/MockupNeedProvider.ts index 10ff48b..c08d59c 100644 --- a/src/provider/MockupNeedProvider.ts +++ b/src/provider/MockupNeedProvider.ts @@ -6,7 +6,6 @@ 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] @@ -26,7 +25,6 @@ function locationScore(propCity: string, preferredLocations: string[]): number { const p = pref.toLowerCase() if (pc.includes(p) || p.includes(pc)) return 1.0 } - // Same canton check const propCanton = CANTON_MAP[pc] if (propCanton) { for (const pref of preferredLocations) { @@ -42,45 +40,38 @@ function computeScore( assetType: string location: { city: string } resultType?: string - rentPricePerSqm: number }, - unit: PropertyUnit, need: Need, + areaSqm: number, + rentPricePerSqm: number | undefined, + isPreMarket = false, ): number | null { if (need.assetType && prop.assetType !== need.assetType) return null 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 - // Area — use unit area (more precise than property aggregate) - if (need.requiredArea && unit.areaSqm) { + if (need.requiredArea && areaSqm) { const { min, max } = need.requiredArea - 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 + if (areaSqm >= min && areaSqm <= max) score += 20 + else if (areaSqm >= min * 0.7 && areaSqm <= max * 1.5) 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² - const effectiveRent = unit.rentPricePerSqm ?? prop.rentPricePerSqm - if (need.budgetRange?.maxPerSqm && effectiveRent) { - const monthlyRate = effectiveRent / 12 + if (need.budgetRange?.maxPerSqm && rentPricePerSqm) { + const monthlyRate = rentPricePerSqm / 12 if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10 else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3 else score -= 8 } - // 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 (unit.schattenmarktRelease?.enabled) { + } else if (isPreMarket) { score = Math.round(score * 0.92) } - // Small jitter so results look natural score += Math.floor(Math.random() * 6) - 2 - return Math.min(97, Math.max(22, score)) } @@ -90,71 +81,106 @@ function strengthFromScore(s: number): string { 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) { const now = new Date().toISOString() for (const prop of propertyStore) { - for (const unit of getEffectiveUnits(prop)) { - const score = computeScore(prop, unit, need) - if (score === null || score < 25) continue + const hasExplicitUnits = (prop.units ?? []).length > 0 - const locS = locationScore(prop.location.city, need.preferredLocations ?? []) - const isGoodLoc = locS >= 0.9 - - // 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') - - // 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, + if (hasExplicitUnits) { + // Multi-unit property: score against aggregate area (tenants renting the whole floor/building) + const propScore = computeScore(prop, need, prop.areaSqm, prop.rentPricePerSqm) + if (propScore !== null && propScore >= 25) { + const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9 + matchStore.push(buildMatch( + prop, undefined, need, propScore, + prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, + isGoodLoc, `${prop.areaSqm.toLocaleString('de-CH')} m²`, 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, + )) + } } } }