feat(supply): per-unit fit-out/cost-bearer/MAB/parking with object fallback (resolveUnitFacts)
- unit: add fitOutByLandlord, mieterausbaubeitragPerSqm, parkingSpots (parking = allocation from object pool) - lib/unitFacts.resolveUnitFacts: single resolver (unit value ?? object value) used by scorer + card adapter - matchSyncService: pre-market unit scored via resolved facts (fit-out, MAB, cost-bearer, parking allocation) - matchCardAdapter: fit-out label/viability/investment via resolveUnitFacts - UnitStructurePanel: consolidate ALL per-unit overrides into the one expandable row editor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation); show parking on row - PreMarketUnitGrid/Panel: drop the duplicate per-unit fit-out select (now in the unit editor) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,9 +27,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
|||||||
}
|
}
|
||||||
return init
|
return init
|
||||||
})
|
})
|
||||||
const [unitFitOut, setUnitFitOut] = useState<Record<string, string>>(() =>
|
|
||||||
Object.fromEntries((p.units ?? []).map(u => [u.id, u.fitOut ?? ''])),
|
|
||||||
)
|
|
||||||
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
|
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const updateProperty = useUpdateProperty()
|
const updateProperty = useUpdateProperty()
|
||||||
@@ -99,23 +96,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUnitFitOut(unitId: string, fitOut: string) {
|
|
||||||
setUnitFitOut(prev => ({ ...prev, [unitId]: fitOut }))
|
|
||||||
setUnitSaving(prev => ({ ...prev, [unitId]: true }))
|
|
||||||
try {
|
|
||||||
await MockupUnitProvider.update(unitId, {
|
|
||||||
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
|
|
||||||
})
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['properties'] })
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
|
||||||
} catch {
|
|
||||||
showToast('Fehler beim Speichern des Ausbaustandards.', 'error')
|
|
||||||
} finally {
|
|
||||||
setUnitSaving(prev => ({ ...prev, [unitId]: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
@@ -213,9 +193,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
|
|||||||
unitSaving={unitSaving}
|
unitSaving={unitSaving}
|
||||||
setUnitStates={setUnitStates}
|
setUnitStates={setUnitStates}
|
||||||
saveUnit={saveUnit}
|
saveUnit={saveUnit}
|
||||||
propertyFitOut={p.hardFacts?.fitOut}
|
|
||||||
unitFitOut={unitFitOut}
|
|
||||||
saveUnitFitOut={saveUnitFitOut}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Box, CircularProgress, MenuItem, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||||
import { EyeOff } from 'lucide-react'
|
import { EyeOff } from 'lucide-react'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
|
||||||
import { FIT_OUT_LABELS } from '../../lib/constants'
|
|
||||||
import { floorLabel } from './PropertyDetailHelpers'
|
import { floorLabel } from './PropertyDetailHelpers'
|
||||||
|
|
||||||
type PropertyUnit = NonNullable<Property['units']>[number]
|
type PropertyUnit = NonNullable<Property['units']>[number]
|
||||||
@@ -19,13 +18,9 @@ interface Props {
|
|||||||
unitSaving: Record<string, boolean>
|
unitSaving: Record<string, boolean>
|
||||||
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, UnitReleaseState>>>
|
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, UnitReleaseState>>>
|
||||||
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
|
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
|
||||||
propertyFitOut?: string
|
|
||||||
unitFitOut: Record<string, string>
|
|
||||||
saveUnitFitOut: (unitId: string, fitOut: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit, propertyFitOut, unitFitOut, saveUnitFitOut }: Props) {
|
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
|
||||||
const inheritLabel = propertyFitOut ? `Wie Objekt (${FIT_OUT_LABELS[propertyFitOut] ?? propertyFitOut})` : 'Wie Objekt'
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||||
@@ -37,7 +32,7 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
|||||||
<Box
|
<Box
|
||||||
key={u.id}
|
key={u.id}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid', gridTemplateColumns: '1fr 150px 140px auto',
|
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||||
gap: 1, alignItems: 'start', py: 0.875,
|
gap: 1, alignItems: 'start', py: 0.875,
|
||||||
borderBottom: '1px solid #f3e8ff',
|
borderBottom: '1px solid #f3e8ff',
|
||||||
'&:last-child': { borderBottom: 'none' },
|
'&:last-child': { borderBottom: 'none' },
|
||||||
@@ -95,22 +90,6 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Ausbaustandard-Override (Fallback: Objekt-Wert) */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={unitFitOut[u.id] ?? ''}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
|
|
||||||
onChange={e => saveUnitFitOut(u.id, e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value="" sx={{ fontSize: '0.72rem' }}>{inheritLabel}</MenuItem>
|
|
||||||
<MenuItem value="SHELL" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.SHELL}</MenuItem>
|
|
||||||
<MenuItem value="BASIC" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.BASIC}</MenuItem>
|
|
||||||
<MenuItem value="FULL" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.FULL}</MenuItem>
|
|
||||||
<MenuItem value="PREMIUM" sx={{ fontSize: '0.72rem' }}>{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* Available-from date */}
|
{/* Available-from date */}
|
||||||
<TextField
|
<TextField
|
||||||
type="date"
|
type="date"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'
|
||||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||||
@@ -7,8 +7,20 @@ import type { Lease } from '../../domain/lease'
|
|||||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
|
import { FIT_OUT_LABELS } from '../../lib/constants'
|
||||||
import { floorLabel } from './PropertyDetailHelpers'
|
import { floorLabel } from './PropertyDetailHelpers'
|
||||||
|
|
||||||
|
const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
|
||||||
|
|
||||||
|
interface UnitDraft {
|
||||||
|
rentPricePerSqm?: number
|
||||||
|
availableFrom?: string
|
||||||
|
fitOut?: string
|
||||||
|
fitOutByLandlord?: boolean
|
||||||
|
mieterausbaubeitragPerSqm?: number
|
||||||
|
parkingSpots?: number
|
||||||
|
}
|
||||||
|
|
||||||
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
function MatchPill({ m }: { m: UnitNeedMatch }) {
|
||||||
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
|
||||||
const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
|
const color = m.matchScore >= 85 ? '#92400e' : '#3730a3'
|
||||||
@@ -51,15 +63,34 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
||||||
const [unitDraft, setUnitDraft] = useState<{ rentPricePerSqm?: number; availableFrom?: string }>({})
|
const [unitDraft, setUnitDraft] = useState<UnitDraft>({})
|
||||||
const updateUnit = useUpdateUnit(p.id)
|
const updateUnit = useUpdateUnit(p.id)
|
||||||
|
|
||||||
function openUnitEditor(u: PropertyUnit) {
|
function openUnitEditor(u: PropertyUnit) {
|
||||||
setUnitDraft({ rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, availableFrom: u.availableFrom })
|
setUnitDraft({
|
||||||
|
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
||||||
|
availableFrom: u.availableFrom,
|
||||||
|
fitOut: u.fitOut ?? '',
|
||||||
|
fitOutByLandlord: u.fitOutByLandlord,
|
||||||
|
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
|
||||||
|
parkingSpots: u.parkingSpots,
|
||||||
|
})
|
||||||
setEditingUnit(u.id)
|
setEditingUnit(u.id)
|
||||||
}
|
}
|
||||||
function saveUnitEditor(unitId: string) {
|
function saveUnitEditor(unitId: string) {
|
||||||
updateUnit.mutate({ unitId, data: { rentPricePerSqm: unitDraft.rentPricePerSqm, availableFrom: unitDraft.availableFrom || undefined } })
|
const effFit = (unitDraft.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
|
||||||
|
const needsBuild = effFit === 'SHELL' || effFit === 'BASIC'
|
||||||
|
updateUnit.mutate({
|
||||||
|
unitId,
|
||||||
|
data: {
|
||||||
|
rentPricePerSqm: unitDraft.rentPricePerSqm,
|
||||||
|
availableFrom: unitDraft.availableFrom || undefined,
|
||||||
|
fitOut: effFit,
|
||||||
|
fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined,
|
||||||
|
mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined,
|
||||||
|
parkingSpots: unitDraft.parkingSpots,
|
||||||
|
},
|
||||||
|
})
|
||||||
setEditingUnit(null)
|
setEditingUnit(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +353,11 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
|
||||||
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
|
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{u.parkingSpots != null && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
|
||||||
|
{u.parkingSpots} PP
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{u.available && (() => {
|
{u.available && (() => {
|
||||||
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
|
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
|
||||||
return (
|
return (
|
||||||
@@ -354,7 +390,52 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, justifyContent: 'flex-end' }}>
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.25, mt: 1.25 }}>
|
||||||
|
<TextField
|
||||||
|
select size="small" label="Ausbaustandard"
|
||||||
|
value={unitDraft.fitOut ?? ''}
|
||||||
|
onChange={e => setUnitDraft(d => ({ ...d, fitOut: e.target.value }))}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Wie Objekt{p.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut})` : ''}</MenuItem>
|
||||||
|
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
|
||||||
|
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
|
||||||
|
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
|
||||||
|
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Parkplätze (Zuteilung)" type="number" size="small"
|
||||||
|
value={unitDraft.parkingSpots ?? ''}
|
||||||
|
onChange={e => setUnitDraft(d => ({ ...d, parkingSpots: parseInt(e.target.value) || undefined }))}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
helperText={p.hardFacts?.parking != null ? `aus Objekt-Pool: ${p.hardFacts.parking}` : 'Leer = Objekt-Wert'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive size="small"
|
||||||
|
value={unitDraft.fitOutByLandlord === undefined ? null : unitDraft.fitOutByLandlord ? 'landlord' : 'tenant'}
|
||||||
|
onChange={(_, v) => { if (v) setUnitDraft(d => ({ ...d, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
|
||||||
|
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
{unitDraft.fitOutByLandlord === false && (
|
||||||
|
<TextField
|
||||||
|
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 160 }}
|
||||||
|
value={unitDraft.mieterausbaubeitragPerSqm ?? ''}
|
||||||
|
onChange={e => setUnitDraft(d => ({ ...d, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 1.25, justifyContent: 'flex-end' }}>
|
||||||
<Button size="small" onClick={() => setEditingUnit(null)} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>
|
<Button size="small" onClick={() => setEditingUnit(null)} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+5
-2
@@ -18,9 +18,12 @@ export interface PropertyUnit {
|
|||||||
available: boolean
|
available: boolean
|
||||||
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
|
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
|
||||||
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
|
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
|
||||||
// Unit-level fit-out override — falls back to property.hardFacts.fitOut when unset.
|
// Unit-level overrides — fall back to property.hardFacts.* when unset.
|
||||||
// Relevant for pre-market, where units of one property are matched individually.
|
// Relevant where units of one property are matched individually (pre-market, multi-unit).
|
||||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||||
|
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
|
||||||
|
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
|
||||||
|
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze
|
||||||
leases?: Lease[] // Mietverträge — current, historical, future
|
leases?: Lease[] // Mietverträge — current, historical, future
|
||||||
/** @deprecated Use leases[].tenant.companyName */
|
/** @deprecated Use leases[].tenant.companyName */
|
||||||
currentTenant?: string
|
currentTenant?: string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
|
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
|
||||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||||
|
import { resolveUnitFacts } from '../../lib/unitFacts'
|
||||||
import { AvailabilityStatus } from '../../domain/enums'
|
import { AvailabilityStatus } from '../../domain/enums'
|
||||||
|
|
||||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
@@ -152,30 +153,30 @@ export function buildMatchCardViewModel(
|
|||||||
preMarketUnit: unit,
|
preMarketUnit: unit,
|
||||||
preMarketAllUnits: property?.units,
|
preMarketAllUnits: property?.units,
|
||||||
fitOutLabel: (() => {
|
fitOutLabel: (() => {
|
||||||
// Einheit-Ausbau hat Vorrang vor dem Objekt-Ausbau (Pre-Market)
|
// Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten)
|
||||||
const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut
|
if (!property) return undefined
|
||||||
|
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||||
if (!fitOut) return undefined
|
if (!fitOut) return undefined
|
||||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||||
const mab = property?.hardFacts?.mieterausbaubeitragPerSqm
|
|
||||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
|
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
|
||||||
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
|
if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB`
|
||||||
return LABELS[fitOut] ?? fitOut
|
return LABELS[fitOut] ?? fitOut
|
||||||
})(),
|
})(),
|
||||||
fitOutViable: (() => {
|
fitOutViable: (() => {
|
||||||
const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut
|
if (!property) return undefined
|
||||||
|
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||||
if (!fitOut) return undefined
|
if (!fitOut) return undefined
|
||||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
||||||
const mab = property?.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
|
||||||
if (fitOut === 'BASIC' && mab >= 150) return true
|
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
|
||||||
if (fitOut === 'SHELL' && mab >= 350) return true
|
|
||||||
return undefined
|
return undefined
|
||||||
})(),
|
})(),
|
||||||
fitOutInvestment: (() => {
|
fitOutInvestment: (() => {
|
||||||
const fitOut = unit?.fitOut ?? property?.hardFacts?.fitOut
|
if (!property) return undefined
|
||||||
|
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
|
||||||
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
||||||
const mab = property?.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
const area = unit?.areaSqm ?? property.areaSqm ?? 0
|
||||||
const area = unit?.areaSqm ?? property?.areaSqm ?? 0
|
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
|
||||||
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
|
|
||||||
})(),
|
})(),
|
||||||
isDivisible: property?.units ? property.units.length > 1 : false,
|
isDivisible: property?.units ? property.units.length > 1 : false,
|
||||||
minDivisibleUnitSqm: (() => {
|
minDivisibleUnitSqm: (() => {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Property, PropertyUnit } from '../domain/property'
|
||||||
|
|
||||||
|
export interface ResolvedUnitFacts {
|
||||||
|
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||||
|
mabPerSqm: number
|
||||||
|
fitOutByLandlord?: boolean
|
||||||
|
parkingSpots?: number
|
||||||
|
rentPricePerSqm: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effektive Ausbau-/Preis-/Parkplatz-Werte für eine Einheit: Einheit-Wert ?? Objekt-Wert.
|
||||||
|
* Parkplätze: Einheit-Zuteilung; bei Einzel-Einheit-Objekten der ganze Objekt-Pool als Fallback.
|
||||||
|
*/
|
||||||
|
export function resolveUnitFacts(property: Property, unit?: PropertyUnit | null): ResolvedUnitFacts {
|
||||||
|
const hf = property.hardFacts
|
||||||
|
const singleUnit = (property.units?.length ?? 0) <= 1
|
||||||
|
return {
|
||||||
|
fitOut: unit?.fitOut ?? hf?.fitOut,
|
||||||
|
mabPerSqm: unit?.mieterausbaubeitragPerSqm ?? hf?.mieterausbaubeitragPerSqm ?? 0,
|
||||||
|
fitOutByLandlord: unit?.fitOutByLandlord ?? hf?.fitOutByLandlord,
|
||||||
|
parkingSpots: unit?.parkingSpots ?? (singleUnit ? hf?.parking : undefined),
|
||||||
|
rentPricePerSqm: unit?.rentPricePerSqm ?? property.rentPricePerSqm,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { Need } from '../domain/need'
|
|||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
import { getEffectiveUnits } from '../domain/property'
|
import { getEffectiveUnits } from '../domain/property'
|
||||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||||
|
import { resolveUnitFacts } from '../lib/unitFacts'
|
||||||
|
|
||||||
function strengthFromScore(s: number): MatchStrength {
|
function strengthFromScore(s: number): MatchStrength {
|
||||||
if (s >= 75) return MatchStrength.STRONG
|
if (s >= 75) return MatchStrength.STRONG
|
||||||
@@ -71,16 +72,16 @@ function scoreProperty(
|
|||||||
overrideArea?: number,
|
overrideArea?: number,
|
||||||
overridePrice?: number,
|
overridePrice?: number,
|
||||||
overrideResultType?: string,
|
overrideResultType?: string,
|
||||||
overrideFitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM',
|
overrideHardFacts?: Partial<NonNullable<Property['hardFacts']>>,
|
||||||
): MatchEngineOutput {
|
): MatchEngineOutput {
|
||||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideFitOut !== undefined) {
|
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) {
|
||||||
return calculateScore(need, {
|
return calculateScore(need, {
|
||||||
...prop,
|
...prop,
|
||||||
areaSqm: overrideArea ?? prop.areaSqm,
|
areaSqm: overrideArea ?? prop.areaSqm,
|
||||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||||
// Einheit-Ausbau überschreibt den Objekt-Ausbau (Fallback bleibt prop.hardFacts.fitOut)
|
// Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts)
|
||||||
hardFacts: overrideFitOut ? { ...prop.hardFacts, fitOut: overrideFitOut } : prop.hardFacts,
|
hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return calculateScore(need, prop)
|
return calculateScore(need, prop)
|
||||||
@@ -104,7 +105,13 @@ export function generateMatchesForNeed(need: Need): void {
|
|||||||
}
|
}
|
||||||
for (const unit of prop.units!) {
|
for (const unit of prop.units!) {
|
||||||
if (!unit.schattenmarktRelease?.enabled) continue
|
if (!unit.schattenmarktRelease?.enabled) continue
|
||||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY, unit.fitOut)
|
const facts = resolveUnitFacts(prop, unit)
|
||||||
|
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY, {
|
||||||
|
fitOut: facts.fitOut,
|
||||||
|
mieterausbaubeitragPerSqm: facts.mabPerSqm,
|
||||||
|
fitOutByLandlord: facts.fitOutByLandlord,
|
||||||
|
parking: facts.parkingSpots,
|
||||||
|
})
|
||||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||||
|
|||||||
Reference in New Issue
Block a user