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
+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,
}