feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6): - FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte - FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J. - Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k) - FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts - BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface) Must-have Kriterien (Nicht prüfbar Fix): - ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft - Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft - Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM fitOut-Datenpflege: - fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL) - MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
a825672197
commit
b2530f9e20
40 files changed
+1755
-733
No files matched your search
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../components/match-card/MatchCardViewModel'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
|
||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||
|
||||
@@ -137,6 +138,38 @@ export function buildMatchCardViewModel(
|
||||
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
|
||||
preMarketUnit: unit,
|
||||
preMarketAllUnits: property?.units,
|
||||
fitOutLabel: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!fitOut) return undefined
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
|
||||
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
|
||||
return LABELS[fitOut] ?? fitOut
|
||||
})(),
|
||||
fitOutViable: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!fitOut) return undefined
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
if (fitOut === 'BASIC' && mab >= 150) return true
|
||||
if (fitOut === 'SHELL' && mab >= 350) return true
|
||||
return undefined
|
||||
})(),
|
||||
fitOutInvestment: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
const area = property.areaSqm ?? 0
|
||||
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
|
||||
})(),
|
||||
isDivisible: property?.units ? property.units.length > 1 : false,
|
||||
minDivisibleUnitSqm: (() => {
|
||||
if (!property?.units || property.units.length <= 1) return undefined
|
||||
const flexible = property.units.filter(u => u.isFlexible && u.minLettableSqm)
|
||||
if (flexible.length > 0) return Math.min(...flexible.map(u => u.minLettableSqm!))
|
||||
return Math.min(...property.units.map(u => u.areaSqm))
|
||||
})(),
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: match.scoreBreakdown.hardMatchScore,
|
||||
softFactorScore: match.scoreBreakdown.softFactorScore,
|
||||
|
||||
@@ -106,6 +106,22 @@ const RULES: KeywordRule[] = [
|
||||
check: (_p) => null, // no structured field yet — always UNKNOWN
|
||||
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
|
||||
},
|
||||
{
|
||||
keywords: ['ausbaugrad', 'ausbaustandard', 'vollausbau', 'modern ausgebaut', 'plug and play', 'bezugsfertig', 'gehoben ausgebaut'],
|
||||
check: (p) => {
|
||||
const fitOut = p.hardFacts?.fitOut
|
||||
if (!fitOut) return null
|
||||
return fitOut === 'FULL' || fitOut === 'PREMIUM'
|
||||
},
|
||||
explanation: (p, ok) => {
|
||||
const fitOut = p.hardFacts?.fitOut
|
||||
if (!fitOut) return 'Ausbaustandard nicht dokumentiert'
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
return ok
|
||||
? `${LABELS[fitOut] ?? fitOut} — moderner Ausbaustandard erfüllt`
|
||||
: `${LABELS[fitOut] ?? fitOut} — nicht auf modernem Ausbauniveau`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Number extraction helpers ──────────────────────────────────────────────────
|
||||
@@ -115,10 +131,25 @@ function extractNumber(text: string): number | null {
|
||||
return m ? parseInt(m[1], 10) : null
|
||||
}
|
||||
|
||||
function extractMinuteLimit(text: string): number | null {
|
||||
const m = text.match(/(?:unter|<|≤|max\.?)\s*(\d+)/) ?? text.match(/(\d+)\s*min/i)
|
||||
return m ? parseInt(m[1], 10) : null
|
||||
}
|
||||
|
||||
function isParkingKeyword(text: string): boolean {
|
||||
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
function isOevKeyword(text: string): boolean {
|
||||
return text.includes('öv-anbindung') || text.includes('öv anbindung') ||
|
||||
text.includes('öv-anschluss') || text.includes('öffentlicher verkehr') ||
|
||||
(text.includes('öv') && (text.includes('min') || text.includes('anbindung')))
|
||||
}
|
||||
|
||||
function isMindestflaecheKeyword(text: string): boolean {
|
||||
return text.includes('mindestfläche') || text.includes('mindestnutzfläche') || text.includes('mindest-fläche')
|
||||
}
|
||||
|
||||
// ── Main scorer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function scoreMustHaves(
|
||||
@@ -137,6 +168,41 @@ export function scoreMustHaves(
|
||||
const results: MustHaveResult[] = criteria.map((raw) => {
|
||||
const lower = raw.toLowerCase()
|
||||
|
||||
// Special case: ÖV-Anbindung with minute limit ("< 5 Min", "unter 10 Min")
|
||||
if (isOevKeyword(lower)) {
|
||||
const minutes = property.softFactors?.publicTransportMinutes
|
||||
if (minutes === undefined) {
|
||||
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'ÖV-Fahrzeit nicht dokumentiert' }
|
||||
}
|
||||
const limit = extractMinuteLimit(lower) ?? 10
|
||||
const passed = minutes <= limit
|
||||
return {
|
||||
criterion: raw,
|
||||
passed,
|
||||
confidence: 'CERTAIN' as const,
|
||||
explanation: passed
|
||||
? `${minutes} Min. zu Fuss zum ÖV — Limit ${limit} Min. erfüllt`
|
||||
: `${minutes} Min. zu Fuss — überschreitet ${limit}-Min.-Limit`,
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: Mindestfläche with m² threshold ("Mindestfläche 600m²")
|
||||
if (isMindestflaecheKeyword(lower)) {
|
||||
const required = extractNumber(lower)
|
||||
if (required === null) {
|
||||
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'Mindestfläche konnte nicht ausgelesen werden' }
|
||||
}
|
||||
const passed = property.areaSqm >= required
|
||||
return {
|
||||
criterion: raw,
|
||||
passed,
|
||||
confidence: 'CERTAIN' as const,
|
||||
explanation: passed
|
||||
? `${property.areaSqm.toLocaleString('de-CH')} m² deckt Mindestfläche ${required} m² ab`
|
||||
: `${property.areaSqm.toLocaleString('de-CH')} m² liegt unter Mindestfläche ${required} m²`,
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: parking with minimum count ("mind. 5 parkplätze")
|
||||
if (isParkingKeyword(lower)) {
|
||||
const required = extractNumber(lower)
|
||||
|
||||
@@ -463,24 +463,50 @@ function scoreMinContractDuration(need: Need, property: Property): ScoreFactor |
|
||||
}
|
||||
}
|
||||
|
||||
const FIT_OUT_UPGRADE_COST: Record<string, number> = {
|
||||
'SHELL_BASIC': 200,
|
||||
'SHELL_FULL': 350,
|
||||
'SHELL_PREMIUM': 450,
|
||||
'BASIC_FULL': 150,
|
||||
'BASIC_PREMIUM': 250,
|
||||
'FULL_PREMIUM': 100,
|
||||
}
|
||||
|
||||
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requiredFitOut) return null
|
||||
const propFitOut = property.hardFacts?.fitOut
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
if (!propFitOut) {
|
||||
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
|
||||
}
|
||||
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
|
||||
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
|
||||
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
if (propLevel >= reqLevel) {
|
||||
return {
|
||||
criterion: 'fitOut', weight: 0.04, score: 100, contribution: 4,
|
||||
explanation: `${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung (${LABELS[need.requiredFitOut] ?? need.requiredFitOut})`,
|
||||
}
|
||||
}
|
||||
// Check if combined budget (tenant + MAB) covers the upgrade gap
|
||||
const upgradeKey = `${propFitOut}_${need.requiredFitOut}`
|
||||
const upgradeCost = FIT_OUT_UPGRADE_COST[upgradeKey]
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
const tenantBudget = need.fitOutBudgetMaxPerSqm ?? 0
|
||||
const totalBudget = mab + tenantBudget
|
||||
if (upgradeCost && totalBudget >= upgradeCost) {
|
||||
const mabNote = mab > 0 ? ` (inkl. CHF ${mab} MAB)` : ''
|
||||
return {
|
||||
criterion: 'fitOut', weight: 0.04, score: 80, contribution: 3.2,
|
||||
explanation: `${LABELS[propFitOut]} — Ausbau auf ${LABELS[need.requiredFitOut]} mit CHF ${totalBudget}/m² Budget überbrückbar${mabNote}`,
|
||||
}
|
||||
}
|
||||
const score = Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
||||
const budgetNote = tenantBudget > 0 || mab > 0
|
||||
? ` (Budget CHF ${totalBudget}/m² — Ausbau ~CHF ${upgradeCost ?? '?'}/m²)`
|
||||
: ''
|
||||
return {
|
||||
criterion: 'fitOut',
|
||||
weight: 0.04,
|
||||
score,
|
||||
contribution: score * 0.04,
|
||||
explanation: propLevel >= reqLevel
|
||||
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
|
||||
: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
|
||||
criterion: 'fitOut', weight: 0.04, score, contribution: score * 0.04,
|
||||
explanation: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich${budgetNote}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user