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:
Benjamin Sutter
2026-06-20 23:44:56 +02:00
parent 647493bf3c
commit fd6ed105bb
7 changed files with 144 additions and 71 deletions
-23
View File
@@ -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}
/> />
)} )}
+3 -24
View File
@@ -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"
+86 -5
View File
@@ -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
View File
@@ -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 -12
View File
@@ -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: (() => {
+25
View File
@@ -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 -5
View File
@@ -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))