Files
property-match/src/domain/property.ts
T
Benjamin Sutter e169f8e310 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>
2026-06-20 18:19:58 +02:00

220 lines
7.2 KiB
TypeScript

import type {
AssetType, ResultType, AvailabilityStatus, AvailabilityType,
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
} from './enums'
import { AvailabilityStatus as AS } from './enums'
import type { PropertyUnit } from './unit'
import type { Lease } from './lease'
// Re-export unit types so all existing imports from 'property' continue to work
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
// ── Location / Address ────────────────────────────────────────────────────────
export interface Location {
city: string
district?: string
canton?: string
region?: string
country: string
coordinates?: { lat: number; lng: number }
}
export interface Address {
street: string
houseNumber: string
postalCode: string
city: string
country: string
}
// ── Source Metadata ───────────────────────────────────────────────────────────
export interface SourceMeta {
sourceType: SourceType | string
sourceLabel?: string
sourceUrl?: string
sourceUpdatedAt?: string
externalId?: string
}
// ── Data Quality ──────────────────────────────────────────────────────────────
export interface DataQuality {
score: number
qualityLevel?: DataQualityLevel
missingCriticalFields: string[]
missingOptionalFields: string[]
lastVerifiedAt?: string
freshness: FreshnessStatus
warnings: string[]
}
// ── Hard Facts ────────────────────────────────────────────────────────────────
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
ceilingHeightM?: number
loadingDocksCount?: number
powerSupplyKva?: number
hasServerRoom?: boolean
isBarrierFree?: boolean
hasAirConditioning?: boolean
hasStorefront?: boolean
}
// ── Soft Factors ──────────────────────────────────────────────────────────────
export interface SoftFactors {
prestigeScore?: number
visibilityScore?: number
footfallScore?: number
commuterAccessScore?: number
talentAccessScore?: number
esgScore?: number
flexibilityScore?: number
expansionPotentialScore?: number
taxEnvironmentScore?: number
// Legacy aliases kept for mock-data backward compatibility
prestige?: number
accessibility?: number
talentAccess?: number
esgRating?: string
passerbyFrequency?: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
parkingSpots?: number
publicTransportMinutes?: number
infrastructureNotes?: string
}
// ── Property ──────────────────────────────────────────────────────────────────
export interface Property {
id: string
organizationId?: string
title: string
assetType: AssetType
resultType: ResultType
location: Location
address: Address
areaSqm: number
areaSqmMin?: number
areaSqmMax?: number
rentPricePerSqm: number
rentChfSqmYear?: number
totalRentMonthly?: number
ancillaryCosts?: number
availabilityDate: string
availabilityStatus: AvailabilityStatus
availabilityType?: AvailabilityType
sourceType: string
sourceLabel?: string
sourceUrl?: string
sourceUpdatedAt?: string
sourceMeta?: SourceMeta
confidenceScore: number
dataQuality: DataQuality
softFactors?: SoftFactors
hardFacts?: PropertyHardFacts
// Legacy fields — kept for backward compat
floorLevel?: number
expansionPotentialSqm?: number
contractDurationMonths?: number
riskLevel?: RiskLevel
description?: string
images?: string[]
floorPlanUrl?: string
/** @deprecated Use unit.leases[].contractDocumentUrl instead */
leaseContractUrl?: string
/** @deprecated Use unit.leases[].contractDocumentName instead */
leaseContractName?: string
propertyNumber?: string
units?: PropertyUnit[]
mapImageUrl?: string
/** @deprecated Use unit.leases instead */
leaseTerm?: string
/** @deprecated Use unit.leases[].startDate instead */
leaseStartDate?: string
/** @deprecated Use unit.leases[].endDate instead */
leaseEndDate?: string
/** @deprecated Use unit.leases[].breakoutOption instead */
breakoutOption?: boolean
/** @deprecated Use unit.leases[].breakoutOptionDate instead */
breakoutOptionDate?: string
/** @deprecated Use unit.leases[].tenant.companyName instead */
currentTenant?: string
importedFrom?: string
importedAt?: string
lastUpdatedAt?: string
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number; anonymous?: boolean }
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
lastReviewedAt?: string
createdAt: string
updatedAt: string
}
export type CreatePropertyInput = Omit<Property, 'id' | 'createdAt' | 'updatedAt'>
export type UpdatePropertyInput = Partial<CreatePropertyInput>
/**
* Returns the rentable units of a property.
* For properties without explicit units, synthesises one unit from property-level data
* so that all matching logic can operate uniformly at unit level.
* Property-level lease fields are synthesised into a proper Lease object for backward compat.
*/
export function getEffectiveUnits(p: Property): PropertyUnit[] {
if (p.units && p.units.length > 0) return p.units
const syntheticLease: Lease | undefined = p.currentTenant
? {
id: `${p.id}-lease`,
unitId: p.id,
tenant: { id: `${p.id}-tenant`, companyName: p.currentTenant },
rentPerSqm: p.rentPricePerSqm,
startDate: p.leaseStartDate ?? '',
endDate: p.leaseEndDate ?? '',
contractDurationYears: p.contractDurationMonths ? Math.round(p.contractDurationMonths / 12) : undefined,
breakoutOption: p.breakoutOption,
breakoutOptionDate: p.breakoutOptionDate,
status: p.leaseEndDate && new Date(p.leaseEndDate) > new Date() ? 'ACTIVE' : 'EXPIRED',
sourceSystem: p.importedFrom ?? 'MANUAL',
contractDocumentUrl: p.leaseContractUrl,
contractDocumentName: p.leaseContractName,
}
: undefined
return [{
id: p.id,
propertyId: p.id,
floorLevel: p.hardFacts?.floor ?? p.floorLevel ?? 0,
unitLabel: undefined,
areaSqm: p.areaSqm,
available: p.availabilityStatus === AS.AVAILABLE_NOW || p.availabilityStatus === AS.AVAILABLE_SOON,
rentPricePerSqm: p.rentPricePerSqm,
leases: syntheticLease ? [syntheticLease] : [],
schattenmarktRelease: p.schattenmarktRelease?.enabled
? { enabled: true, availableFrom: p.leaseEndDate }
: undefined,
}]
}