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:
1 parent
7a0909e36a
commit
e169f8e310
18 files changed
+313
-104
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 0–100 (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,
|
||||
|
||||
Reference in new issue
Block a user