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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')} m²`}
|
||||
/>
|
||||
{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')} m² ÷ ${AMORTIZATION_YEARS} J.`
|
||||
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Total/Jahr"
|
||||
value={chfRange(totalPerYear.min, totalPerYear.max)}
|
||||
value={chf(totalPerYear)}
|
||||
isTotal
|
||||
isWarning={isWarning}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user