27c53f3af1
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore exclusively — allFactors always populated, no more dual-system (computeScore removed), weights from weightingProfile reflected in every breakdown - ScoreInlineBreakdown: new component shows hard/soft criteria with importance labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards - MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel - MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability' - aiService budget parser: values < 100 treated as monthly and multiplied by 12 to produce correct annual CHF/m²/year value — fixes 0-result searches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
6.7 KiB
TypeScript
158 lines
6.7 KiB
TypeScript
import type { INeedProvider, NeedFilters } from './INeedProvider'
|
|
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
|
import { mockNeeds } from '../mock-data/needs'
|
|
import { matchStore } from './MockupMatchProvider'
|
|
import { propertyStore } from './MockupPropertyProvider'
|
|
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
|
|
import type { Match } from '../domain/match'
|
|
import type { MatchEngineOutput } from '../domain/scoring'
|
|
import type { Property } from '../domain/property'
|
|
import { getEffectiveUnits } from '../domain/property'
|
|
import { calculateScore } from '../features/matching/scoreCalculator'
|
|
|
|
const store: Need[] = [...mockNeeds]
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
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: crypto.randomUUID(),
|
|
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],
|
|
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): MatchEngineOutput {
|
|
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
|
return calculateScore(need, {
|
|
...prop,
|
|
areaSqm: overrideArea ?? prop.areaSqm,
|
|
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
|
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
|
})
|
|
}
|
|
return calculateScore(need, prop)
|
|
}
|
|
|
|
function generateSyntheticMatches(need: Need) {
|
|
const now = new Date().toISOString()
|
|
const MIN_SCORE = 22
|
|
|
|
for (const prop of propertyStore) {
|
|
const hasExplicitUnits = (prop.units ?? []).length > 0
|
|
|
|
if (hasExplicitUnits) {
|
|
// Whole-property match (multi-unit building)
|
|
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))
|
|
}
|
|
|
|
// Per released unit (pre-market)
|
|
for (const unit of prop.units!) {
|
|
if (!unit.schattenmarktRelease?.enabled) continue
|
|
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
|
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 {
|
|
// Single-unit / synthesised units
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Provider ───────────────────────────────────────────────────────────────────
|
|
|
|
export const MockupNeedProvider: INeedProvider = {
|
|
async getAll(filters?: NeedFilters) {
|
|
let results = [...store]
|
|
if (filters?.assetType) results = results.filter(n => n.assetType === filters.assetType)
|
|
if (filters?.organizationId) results = results.filter(n => n.organizationId === filters.organizationId)
|
|
if (filters?.companyName) results = results.filter(n => n.companyName.toLowerCase().includes(filters.companyName!.toLowerCase()))
|
|
return results
|
|
},
|
|
async getById(id) {
|
|
return store.find(n => n.id === id) ?? null
|
|
},
|
|
async create(data: CreateNeedInput) {
|
|
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
|
store.push(next)
|
|
generateSyntheticMatches(next)
|
|
return next
|
|
},
|
|
async update(id, data: UpdateNeedInput) {
|
|
const idx = store.findIndex(n => n.id === id)
|
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
|
// Remove old matches and recompute with updated weights
|
|
const updated = store[idx]
|
|
const startLen = matchStore.length
|
|
for (let i = startLen - 1; i >= 0; i--) {
|
|
if (matchStore[i].needId === id) matchStore.splice(i, 1)
|
|
}
|
|
generateSyntheticMatches(updated)
|
|
return updated
|
|
},
|
|
async remove(id) {
|
|
const idx = store.findIndex(n => n.id === id)
|
|
store.splice(idx, 1)
|
|
},
|
|
}
|
|
|
|
// Compute matches for all pre-existing needs so scores reflect their weightingProfile
|
|
for (const need of store) {
|
|
generateSyntheticMatches(need)
|
|
}
|