feat(matching): annuity-based fit-out cost in score + fix unapplied DQ/confidence modifiers

Mieterausbau / fit-out economics — surface true total cost of occupancy:
- Annuity calc (annuityFactor + effectiveAnnualBurdenPerSqm) replaces straight-line ÷5; FITOUT_ANNUITY_RATE=5%
- New hardFacts.fitOutByLandlord: "Wer baut aus?" toggle in NewListing — landlord-borne fit-out is priced into rent (no surcharge), tenant-borne SHELL/BASIC adds annuitized cost minus MAB
- scoreBudget now compares effective annual burden (rent + fit-out annuity) vs budget instead of cold rent only; FULL/PREMIUM and landlord-borne unchanged
- FitOutCostPanel + CompareTableBody compute annuitized, tenant-aware burden
- Central FIT_OUT_LABELS with industry/international vocabulary (Rohbau·Core&Shell, Edelrohbau·CAT A, etc.)
- Activate existing generateFitOutAdvice via useFitOutAdvice hook + new FitOutAdvicePanel (MIETERAUSBAU/BKZ/MAB-Amortisation + negotiation tip), shown for tenant-borne SHELL/BASIC
- MAB field only asked when tenant builds out (optional) — one new toggle, no extra data burden for property managers

Fix: DQ/confidence modifiers were computed but never applied to finalScore (hardcoded 0 in output) — now folded into rawFinal and exposed. Trust-first: weak data quality lowers the score. Resolves 3 pre-existing red tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-20 18:19:58 +02:00
parent 7a0909e36a
commit e169f8e310
18 changed files with 313 additions and 104 deletions
+11 -24
View File
@@ -20,8 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
// Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
})
const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
@@ -205,7 +192,7 @@ export function CompareTableBody({
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
@@ -213,7 +200,7 @@ export function CompareTableBody({
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -0,0 +1,82 @@
import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { Lightbulb, MessageSquareQuote } from 'lucide-react'
import { useFitOutAdvice } from '../../hooks/useAI'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const RECOMMENDATION_META: Record<string, { label: string; bg: string; border: string; fg: string }> = {
MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
}
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
}
export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
const { data, isLoading, isError } = useFitOutAdvice({
fitOut,
areaSqm,
mabPerSqm,
requiredFitOut,
tenantBudgetPerSqm,
monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
})
// Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
if (isError) return null
if (isLoading || !data) {
return (
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt</Typography>
</Paper>
)
}
const advice = data.data
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Lightbulb size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
{advice.explanation}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
</Typography>
</Box>
{data.provenance?.fallbackUsed && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
Hinweis: KI nicht verfügbar Richtwert-basierte Empfehlung.
</Typography>
)}
</Paper>
)
}
+27 -46
View File
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
fitOutByLandlord?: boolean
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutPerYear = fitOutPerSqm * areaSqm
const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
// Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
const hasSurcharge = fitOutPerYear > 0
const isWarning = totalPerYear > rentPerYear * 1.3
const disclaimer = hasSurcharge
? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
: null
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
{hasSurcharge && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
{fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chf(fitOutPerYear)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
`${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} ÷ ${AMORTIZATION_YEARS} J.`
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
value={chf(totalPerYear)}
isTotal
isWarning={isWarning}
/>
+1
View File
@@ -13,4 +13,5 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -1,11 +1,16 @@
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
fitOutByLandlord: boolean
onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
@@ -64,6 +63,40 @@ export function TechnicalDetailsSection({
/>
</Box>
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
{showFitOutResponsibility && (
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Wer baut aus?</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive
size="small"
value={fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{fitOutByLandlord ? (
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
Ausbau im Mietzins enthalten bitte die <strong>bezugsfertige</strong> Miete eintragen.
Es wird kein Aufschlag berechnet.
</Typography>
) : (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
/>
)}
</Box>
</Box>
)}
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel