feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6): - FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte - FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J. - Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k) - FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts - BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface) Must-have Kriterien (Nicht prüfbar Fix): - ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft - Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft - Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM fitOut-Datenpflege: - fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL) - MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -131,3 +131,11 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
||||
LOW: 'Niedrig',
|
||||
INCOMPLETE: 'Unvollständig',
|
||||
}
|
||||
|
||||
// Swiss CRB/BKP fit-out investment benchmarks (CHF/m², tenant's cost)
|
||||
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
|
||||
SHELL: { min: 800, max: 1500 },
|
||||
BASIC: { min: 400, max: 800 },
|
||||
FULL: { min: 50, max: 200 },
|
||||
PREMIUM: { min: 0, max: 50 },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
|
||||
|
||||
export interface FitOutInvestment {
|
||||
grossPerSqm: { min: number; max: number }
|
||||
mabOffset: number
|
||||
netPerSqm: { min: number; max: number }
|
||||
netTotal: { min: number; max: number }
|
||||
isFullyCovered: boolean
|
||||
shortLabel: string
|
||||
detailLabel: string
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
export function calcFitOutInvestment(
|
||||
fitOut: string,
|
||||
areaSqm: number,
|
||||
mabPerSqm: number,
|
||||
tenantBudgetPerSqm: number,
|
||||
): FitOutInvestment | null {
|
||||
const benchmarks = FIT_OUT_COST_CHF_PER_SQM[fitOut]
|
||||
if (!benchmarks) return null
|
||||
|
||||
const gross = benchmarks
|
||||
const totalOffset = mabPerSqm + tenantBudgetPerSqm
|
||||
const netMin = Math.max(0, gross.min - totalOffset)
|
||||
const netMax = Math.max(0, gross.max - totalOffset)
|
||||
|
||||
const netTotal = {
|
||||
min: Math.round(netMin * areaSqm),
|
||||
max: Math.round(netMax * areaSqm),
|
||||
}
|
||||
|
||||
const isFullyCovered = netTotal.max <= 0
|
||||
|
||||
const shortLabel = isFullyCovered
|
||||
? 'Investition gedeckt'
|
||||
: netTotal.min === netTotal.max
|
||||
? `Est. ${formatChfK(netTotal.min)}`
|
||||
: `Est. ${formatChfK(netTotal.min)}–${formatChfK(netTotal.max)}`
|
||||
|
||||
const detailLabel = isFullyCovered
|
||||
? 'Ausbaukosten durch MAB/Budget gedeckt'
|
||||
: `CHF ${netMin.toLocaleString('de-CH')}–${netMax.toLocaleString('de-CH')}/m² Nettoinvestition`
|
||||
|
||||
return {
|
||||
grossPerSqm: gross,
|
||||
mabOffset: mabPerSqm,
|
||||
netPerSqm: { min: netMin, max: netMax },
|
||||
netTotal,
|
||||
isFullyCovered,
|
||||
shortLabel,
|
||||
detailLabel,
|
||||
}
|
||||
}
|
||||
@@ -145,3 +145,34 @@ export function getMarketRent(city: string, assetType: string): number | null {
|
||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
||||
return intel.medianRentOffice
|
||||
}
|
||||
|
||||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||||
|
||||
const CITY_COORDS: Record<string, { lat: number; lng: number }> = {
|
||||
'Zürich': { lat: 47.3769, lng: 8.5417 },
|
||||
'Zürich-West': { lat: 47.3862, lng: 8.5195 },
|
||||
'Zürich-Nord': { lat: 47.4108, lng: 8.5450 },
|
||||
'Basel': { lat: 47.5596, lng: 7.5886 },
|
||||
'Bern': { lat: 46.9480, lng: 7.4474 },
|
||||
'Luzern': { lat: 47.0502, lng: 8.3093 },
|
||||
'Genf': { lat: 46.2044, lng: 6.1432 },
|
||||
'Lausanne': { lat: 46.5197, lng: 6.6323 },
|
||||
'Zug': { lat: 47.1661, lng: 8.5158 },
|
||||
'Winterthur': { lat: 47.5004, lng: 8.7256 },
|
||||
'St. Gallen': { lat: 47.4245, lng: 9.3767 },
|
||||
'Lugano': { lat: 46.0037, lng: 8.9511 },
|
||||
'Baar': { lat: 47.1965, lng: 8.5286 },
|
||||
'Muttenz': { lat: 47.5218, lng: 7.6468 },
|
||||
'Pratteln': { lat: 47.5163, lng: 7.6926 },
|
||||
'Reinach BL': { lat: 47.4948, lng: 7.5946 },
|
||||
'Allschwil': { lat: 47.5529, lng: 7.5368 },
|
||||
'Binningen': { lat: 47.5363, lng: 7.5687 },
|
||||
}
|
||||
|
||||
export function lookupCityCoords(location: string): { lat: number; lng: number } | null {
|
||||
if (CITY_COORDS[location]) return CITY_COORDS[location]
|
||||
// Prefix fallback: "Zürich Kreis 5" → "Zürich"
|
||||
const base = location.split(' ')[0]
|
||||
const key = Object.keys(CITY_COORDS).find(k => k.startsWith(base) || base === k)
|
||||
return key ? CITY_COORDS[key] : null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user