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:
Benjamin SutterandClaude Sonnet 4.6 committed 2026-06-06 20:09:55 +02:00
1 parent a825672197
commit b2530f9e20
40 files changed
+1755 -733

No files matched your search

+33
View File
@@ -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,
+66
View File
@@ -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)
+35 -9
View File
@@ -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}`,
}
}