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:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user