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
+2
View File
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
fitOutByLandlord?: boolean
parking?: number
publicTransportScore?: number
usageType?: string
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
expect(output.excludedReason).toBeTruthy()
})
// ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
function budgetFactor(need: ReturnType<typeof makeNeed>, prop: ReturnType<typeof makeProperty>) {
return calculateScore(need, prop).allHardFactors.find(f => f.criterion === 'budget')!
}
it('tenant-borne SHELL lowers the budget factor vs a fitted-out (FULL) property', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const shell = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const full = makeProperty({ ...base, hardFacts: { fitOut: 'FULL' } })
expect(budgetFactor(need, shell).score).toBeLessThan(budgetFactor(need, full).score)
})
it('landlord-borne SHELL keeps the budget factor (no fit-out surcharge)', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const tenant = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const landlord = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL', fitOutByLandlord: true } })
expect(budgetFactor(need, landlord).score).toBeGreaterThan(budgetFactor(need, tenant).score)
})
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
const need = makeNeed()
const prop = makeProperty()
+23 -9
View File
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
// ── Profile resolution ────────────────────────────────────────────────────────
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
const rent = property.rentPricePerSqm
// Effektive Jahresbelastung: Kaltmiete + annuitätischer Ausbau-Aufschlag (nur wenn Mieter ausbaut)
const { effectivePerSqm: rent, fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut: property.hardFacts?.fitOut,
rentPricePerSqm: property.rentPricePerSqm,
mabPerSqm: property.hardFacts?.mieterausbaubeitragPerSqm ?? 0,
fitOutByLandlord: property.hardFacts?.fitOutByLandlord,
})
// Erklärt den Ausbau-Anteil transparent, wenn er den Score beeinflusst
const fitOutNote = fitOutPerSqm > 0 ? ` (inkl. CHF ${fitOutPerSqm}/m² Ausbau-Annuität)` : ''
let score: number
let explanation: string
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
explanation = `Effektive Belastung CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²${fitOutNote}`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else {
score = 20
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
}
}
@@ -610,8 +620,12 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
// ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
// abzgl. severePenalty, clamped 0100 (Trust-first: schwache Datenlage senkt den Score) ──
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40
+ dataQualityModifier + confidenceModifier - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier: 0,
confidenceModifier: 0,
dataQualityModifier,
confidenceModifier,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
+11 -2
View File
@@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload } from '../services/aiService'
import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService'
export function useParseNeed() {
return useMutation({
@@ -25,3 +25,12 @@ export function useParseListingText() {
mutationFn: (text: string) => parseListingText(text),
})
}
export function useFitOutAdvice(input: FitOutAdviceInput | null) {
return useQuery({
queryKey: ['fitOutAdvice', input],
queryFn: () => aiService.generateFitOutAdvice(input!),
enabled: !!input,
staleTime: Infinity,
})
}
+7 -4
View File
@@ -43,6 +43,7 @@ export interface NewListingFormState {
// Technical
floor: string
fitOut: string
fitOutByLandlord: boolean
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
@@ -80,6 +81,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void
setFitOut: (v: string) => void
setFitOutByLandlord: (v: boolean) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
@@ -117,6 +119,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
const [fitOutByLandlord, setFitOutByLandlord] = useState(pre.fitOutByLandlord ?? false)
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
@@ -169,7 +172,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}),
{
@@ -185,7 +188,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setFloor(''); setFitOut(''); setFitOutByLandlord(false); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
@@ -195,7 +198,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return {
assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
@@ -205,7 +208,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
+12
View File
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5
// Kalkulationszins p.a. für die annuitätische Amortisation der Ausbauinvestition (Schweizer Richtwert)
export const FITOUT_ANNUITY_RATE = 0.05
// Zentrale Ausbaustandard-Labels — branchenüblich + internationale Synonyme (CAT A/B, Core & Shell).
// Single Source of Truth für Dropdown, Panel, Score, Vergleich.
export const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau · Core & Shell',
BASIC: 'Edelrohbau · CAT A',
FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play',
}
+42 -1
View File
@@ -1,15 +1,49 @@
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
import { FIT_OUT_COST_CHF_PER_SQM, FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
annualBurden: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
// Annuitätenfaktor: a = i(1+i)^n / ((1+i)^n 1); bei i=0 → 1/n (lineare Amortisation als Fallback)
export function annuityFactor(rate: number, years: number): number {
if (years <= 0) return 0
if (rate === 0) return 1 / years
const q = Math.pow(1 + rate, years)
return (rate * q) / (q - 1)
}
// Bezugsfertige Stufen — kein Ausbau-Aufschlag (Mieter muss nicht mehr investieren)
const FITOUT_READY = new Set(['FULL', 'PREMIUM'])
/**
* Effektive Jahresbelastung pro m². Grundsatz: die quotierte Miete gilt IMMER als realer Preis —
* die Plattform erfindet keinen Aufschlag. Nur wenn der Mieter ausbaut (SHELL/BASIC), wird die
* versteckte Ausbaukost annuitätisch sichtbar gemacht (abzgl. MAB). Übernimmt der Vermieter, ist der
* Ausbau bereits in der Miete enthalten → kein Aufschlag, keine Mieter-Capex.
*/
export function effectiveAnnualBurdenPerSqm(p: {
fitOut?: string
rentPricePerSqm: number
mabPerSqm: number
fitOutByLandlord?: boolean
}): { rentPerSqm: number; fitOutPerSqm: number; effectivePerSqm: number } {
if (p.fitOutByLandlord || FITOUT_READY.has(p.fitOut ?? '')) {
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm: 0, effectivePerSqm: p.rentPricePerSqm }
}
const bm = FIT_OUT_COST_CHF_PER_SQM[p.fitOut ?? '']
const grossMid = bm ? (bm.min + bm.max) / 2 : 0
const net = Math.max(0, grossMid - p.mabPerSqm)
const fitOutPerSqm = Math.round(net * annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS))
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm, effectivePerSqm: p.rentPricePerSqm + fitOutPerSqm }
}
function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k`
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
max: Math.round(netMax * areaSqm),
}
const factor = annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS)
const annualBurden = {
min: Math.round(netTotal.min * factor),
max: Math.round(netTotal.max * factor),
}
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
annualBurden,
isFullyCovered,
shortLabel,
detailLabel,
+1 -1
View File
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8,
publicTransportMinutes: 5,
},
hardFacts: { fitOut: 'BASIC' },
hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
+16 -2
View File
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -220,7 +220,21 @@ export default function MatchDetail() {
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
fitOutByLandlord={property.hardFacts.fitOutByLandlord}
/>
)}
{/* ── KI-Ausbauempfehlung: nur wenn Mieter ausbaut (SHELL/BASIC, kein Vermieter-Übernahme) ── */}
{!isFuture && property?.hardFacts?.fitOut
&& !property.hardFacts.fitOutByLandlord
&& (property.hardFacts.fitOut === 'SHELL' || property.hardFacts.fitOut === 'BASIC') && (
<FitOutAdvicePanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
requiredFitOut={need?.requiredFitOut}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
/>
)}
+1
View File
@@ -81,6 +81,7 @@ export default function NewListing() {
<TechnicalDetailsSection
floor={form.floor} onFloorChange={form.setFloor}
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
+7 -4
View File
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
}
import { FIT_OUT_LABELS } from '../../lib/constants'
export const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basis-Ausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
{ value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
{ value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
{ value: 'FULL', label: FIT_OUT_LABELS.FULL },
{ value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
]
export interface LocationState {
@@ -51,6 +53,7 @@ export interface LocationState {
propertyId?: string
floor?: string
fitOut?: string
fitOutByLandlord?: boolean
parking?: number
ceilingHeight?: number
mieterausbaubeitragPerSqm?: number
+5 -2
View File
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
softLevels: Record<string, string>
floor: string
fitOut: string
fitOutByLandlord?: boolean
parking: string
ceilingHeight: string
images: string[]
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
@@ -45,7 +46,9 @@ export function buildCreatePropertyInput(fields: {
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
fitOutByLandlord: fitOutByLandlord || undefined,
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
+2
View File
@@ -22,5 +22,7 @@ export type {
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
FitOutAdvice,
FitOutAdviceInput,
} from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser'