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:
@@ -399,6 +399,20 @@ 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 */}
|
||||||
|
{vm.preMarketUnit?.unitLabel && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${floorLabel(vm.preMarketUnit.floorLevel)} · ${vm.preMarketUnit.unitLabel}`}
|
||||||
|
sx={{ height: 18, fontSize: '0.62rem', bgcolor: '#f1f5f9', color: '#374151', border: '1px solid #e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem' }}>
|
||||||
|
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Regular explainability summary */}
|
{/* Regular explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface Match {
|
|||||||
resultId?: string // preferred: generic result ID
|
resultId?: string // preferred: generic result ID
|
||||||
resultType?: ResultType
|
resultType?: ResultType
|
||||||
propertyId: string // legacy alias for resultId (VERIFIED_PORTFOLIO)
|
propertyId: string // legacy alias for resultId (VERIFIED_PORTFOLIO)
|
||||||
|
unitId?: string // specific rentable unit this match refers to
|
||||||
|
|
||||||
// Scoring
|
// Scoring
|
||||||
matchScore: number // 0–100
|
matchScore: number // 0–100
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
AssetType, ResultType, AvailabilityStatus, AvailabilityType,
|
AssetType, ResultType, AvailabilityStatus, AvailabilityType,
|
||||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
|
import { AvailabilityStatus as AS } from './enums'
|
||||||
|
|
||||||
// ── Location / Address ────────────────────────────────────────────────────────
|
// ── Location / Address ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -196,3 +197,27 @@ export interface Property {
|
|||||||
|
|
||||||
export type CreatePropertyInput = Omit<Property, 'id' | 'createdAt' | 'updatedAt'>
|
export type CreatePropertyInput = Omit<Property, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
export type UpdatePropertyInput = Partial<CreatePropertyInput>
|
export type UpdatePropertyInput = Partial<CreatePropertyInput>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the rentable units of a property.
|
||||||
|
* For properties without explicit units, synthesises one unit from property-level data
|
||||||
|
* so that all matching logic can operate uniformly at unit level.
|
||||||
|
*/
|
||||||
|
export function getEffectiveUnits(p: Property): PropertyUnit[] {
|
||||||
|
if (p.units && p.units.length > 0) return p.units
|
||||||
|
return [{
|
||||||
|
id: p.id,
|
||||||
|
propertyId: p.id,
|
||||||
|
floorLevel: p.hardFacts?.floor ?? p.floorLevel ?? 0,
|
||||||
|
unitLabel: undefined,
|
||||||
|
areaSqm: p.areaSqm,
|
||||||
|
available: p.availabilityStatus === AS.AVAILABLE_NOW || p.availabilityStatus === AS.AVAILABLE_SOON,
|
||||||
|
rentPricePerSqm: p.rentPricePerSqm,
|
||||||
|
currentTenant: p.currentTenant,
|
||||||
|
leaseTerm: p.leaseTerm,
|
||||||
|
leaseEndDate: p.leaseEndDate,
|
||||||
|
schattenmarktRelease: p.schattenmarktRelease?.enabled
|
||||||
|
? { enabled: true, availableFrom: p.leaseEndDate }
|
||||||
|
: undefined,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
|||||||
resultType: 'VERIFIED_PORTFOLIO'
|
resultType: 'VERIFIED_PORTFOLIO'
|
||||||
property: Property
|
property: Property
|
||||||
match: Match
|
match: Match
|
||||||
|
unit?: PropertyUnit // specific unit this match refers to
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalMarketResult extends UnifiedResultBase {
|
export interface ExternalMarketResult extends UnifiedResultBase {
|
||||||
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
|
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
|
||||||
property: Property
|
property: Property
|
||||||
match: Match
|
match: Match
|
||||||
|
unit?: PropertyUnit // specific unit this match refers to
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FutureAvailabilityResult extends UnifiedResultBase {
|
export interface FutureAvailabilityResult extends UnifiedResultBase {
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export function buildMatchCardViewModel(
|
|||||||
const signal = isFuture
|
const signal = isFuture
|
||||||
? (result as FutureAvailabilityResult).signal
|
? (result as FutureAvailabilityResult).signal
|
||||||
: undefined
|
: undefined
|
||||||
const unit = isFuture ? (result as FutureAvailabilityResult).unit : undefined
|
const unit = isFuture
|
||||||
|
? (result as FutureAvailabilityResult).unit
|
||||||
|
: (result as VerifiedPortfolioResult | ExternalMarketResult).unit
|
||||||
|
|
||||||
const city = property?.location?.city
|
const city = property?.location?.city
|
||||||
const district = property?.location?.district
|
const district = property?.location?.district
|
||||||
@@ -121,7 +123,7 @@ export function buildMatchCardViewModel(
|
|||||||
signalUnconfirmedFacts: signal?.unconfirmedFacts,
|
signalUnconfirmedFacts: signal?.unconfirmedFacts,
|
||||||
signalAreaSqmEstimate: signal?.areaSqmEstimate,
|
signalAreaSqmEstimate: signal?.areaSqmEstimate,
|
||||||
propertyId: property?.id ?? signal?.propertyId,
|
propertyId: property?.id ?? signal?.propertyId,
|
||||||
unitId: unit?.id ?? signal?.unitId,
|
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
|
||||||
preMarketUnit: unit,
|
preMarketUnit: unit,
|
||||||
preMarketAllUnits: property?.units,
|
preMarketAllUnits: property?.units,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export function useUnifiedResults(needId?: string) {
|
|||||||
resultType: 'FUTURE_AVAILABILITY', signal, match, property, unit: backingUnit }]
|
resultType: 'FUTURE_AVAILABILITY', signal, match, property, unit: backingUnit }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve specific unit if the match carries a unitId
|
||||||
|
const matchUnit = match.unitId
|
||||||
|
? (property.units?.find(u => u.id === match.unitId) ?? undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
|
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
|
||||||
const result: ExternalMarketResult = {
|
const result: ExternalMarketResult = {
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
@@ -67,6 +72,7 @@ export function useUnifiedResults(needId?: string) {
|
|||||||
resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
||||||
property,
|
property,
|
||||||
match,
|
match,
|
||||||
|
unit: matchUnit,
|
||||||
}
|
}
|
||||||
return [result]
|
return [result]
|
||||||
}
|
}
|
||||||
@@ -78,6 +84,7 @@ export function useUnifiedResults(needId?: string) {
|
|||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
property,
|
property,
|
||||||
match,
|
match,
|
||||||
|
unit: matchUnit,
|
||||||
}
|
}
|
||||||
return [result]
|
return [result]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { matchStore } from './MockupMatchProvider'
|
|||||||
import { propertyStore } from './MockupPropertyProvider'
|
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 type { PropertyUnit } from '../domain/property'
|
||||||
|
|
||||||
const store: Need[] = [...mockNeeds]
|
const store: Need[] = [...mockNeeds]
|
||||||
|
|
||||||
@@ -37,39 +39,42 @@ function locationScore(propCity: string, preferredLocations: string[]): number {
|
|||||||
|
|
||||||
function computeScore(
|
function computeScore(
|
||||||
prop: {
|
prop: {
|
||||||
assetType: string; areaSqm: number; rentPricePerSqm: number;
|
assetType: string
|
||||||
location: { city: string }; resultType?: string;
|
location: { city: string }
|
||||||
schattenmarktRelease?: { enabled?: boolean }
|
resultType?: string
|
||||||
|
rentPricePerSqm: number
|
||||||
},
|
},
|
||||||
|
unit: PropertyUnit,
|
||||||
need: Need,
|
need: Need,
|
||||||
): 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 → 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
|
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||||
|
|
||||||
// Area overlap (+0-20)
|
// Area — use unit area (more precise than property aggregate)
|
||||||
if (need.requiredArea && prop.areaSqm) {
|
if (need.requiredArea && unit.areaSqm) {
|
||||||
const { min, max } = need.requiredArea
|
const { min, max } = need.requiredArea
|
||||||
if (prop.areaSqm >= min && prop.areaSqm <= max) score += 20
|
if (unit.areaSqm >= min && unit.areaSqm <= max) score += 20
|
||||||
else if (prop.areaSqm >= min * 0.7 && prop.areaSqm <= max * 1.5) score += 10
|
else if (unit.areaSqm >= min * 0.7 && unit.areaSqm <= max * 1.5) score += 10
|
||||||
else if (prop.areaSqm < min * 0.5 || prop.areaSqm > max * 2) 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²
|
// Budget — prefer unit-level rent, fall back to property rent; both stored as annual CHF/m²
|
||||||
if (need.budgetRange?.maxPerSqm && prop.rentPricePerSqm) {
|
const effectiveRent = unit.rentPricePerSqm ?? prop.rentPricePerSqm
|
||||||
const monthlyRate = prop.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 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') {
|
if (prop.resultType === 'FUTURE_AVAILABILITY') {
|
||||||
score = Math.round(score * 0.82)
|
score = Math.round(score * 0.82)
|
||||||
} else if (prop.schattenmarktRelease?.enabled) {
|
} else if (unit.schattenmarktRelease?.enabled) {
|
||||||
score = Math.round(score * 0.92)
|
score = Math.round(score * 0.92)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,28 +94,39 @@ function generateSyntheticMatches(need: Need) {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
for (const prop of propertyStore) {
|
for (const prop of propertyStore) {
|
||||||
const score = computeScore(prop, need)
|
for (const unit of getEffectiveUnits(prop)) {
|
||||||
|
const score = computeScore(prop, unit, need)
|
||||||
if (score === null || score < 25) continue
|
if (score === null || score < 25) continue
|
||||||
|
|
||||||
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||||
const isGoodLoc = locS >= 0.9
|
const isGoodLoc = locS >= 0.9
|
||||||
|
|
||||||
// VERIFIED_PORTFOLIO properties with schattenmarktRelease appear as FUTURE_AVAILABILITY
|
// Unit with pre-market release → FUTURE_AVAILABILITY so demand users see PRE-MARKET VERIFIED card
|
||||||
// in demand search so demand users see the PRE-MARKET VERIFIED card
|
// External future signals (property-level) → FUTURE_AVAILABILITY
|
||||||
const effectiveResultType =
|
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||||
prop.resultType === 'VERIFIED_PORTFOLIO' && prop.schattenmarktRelease?.enabled
|
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
const effectiveResultType = (isPreMarket || isFutureProp)
|
||||||
? 'FUTURE_AVAILABILITY'
|
? 'FUTURE_AVAILABILITY'
|
||||||
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
|
: (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 = {
|
const match: Match = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
propertyId: prop.id,
|
propertyId: prop.id,
|
||||||
|
unitId: unit.id,
|
||||||
needId: need.id,
|
needId: need.id,
|
||||||
resultId: prop.id,
|
resultId,
|
||||||
resultType: effectiveResultType as Match['resultType'],
|
resultType: effectiveResultType as Match['resultType'],
|
||||||
matchScore: score,
|
matchScore: score,
|
||||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||||
status: score >= 75 ? MatchStatus.PENDING_REVIEW : MatchStatus.PENDING_REVIEW,
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
scoreBreakdown: {
|
scoreBreakdown: {
|
||||||
hardMatchScore: score + 5,
|
hardMatchScore: score + 5,
|
||||||
softFactorScore: score - 5,
|
softFactorScore: score - 5,
|
||||||
@@ -120,7 +136,7 @@ function generateSyntheticMatches(need: Need) {
|
|||||||
},
|
},
|
||||||
positiveFactors: isGoodLoc
|
positiveFactors: isGoodLoc
|
||||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
? [{ 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` }],
|
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${unitAreaLabel} verfügbar` }],
|
||||||
negativeFactors: !isGoodLoc
|
negativeFactors: !isGoodLoc
|
||||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||||
: [],
|
: [],
|
||||||
@@ -128,7 +144,7 @@ function generateSyntheticMatches(need: Need) {
|
|||||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||||
: [],
|
: [],
|
||||||
explainabilitySummary: isGoodLoc
|
explainabilitySummary: isGoodLoc
|
||||||
? `${prop.location.city} trifft den Standortwunsch. Objekt entspricht den Kernkriterien.`
|
? `${prop.location.city} trifft den Standortwunsch. Einheit (${unitAreaLabel}) entspricht den Kernkriterien.`
|
||||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||||
@@ -140,6 +156,7 @@ function generateSyntheticMatches(need: Need) {
|
|||||||
|
|
||||||
matchStore.push(match)
|
matchStore.push(match)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Provider ───────────────────────────────────────────────────────────────────
|
// ── Provider ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user