feat: unit-level matching — each floor lettable independently or as bundle
- PropertyUnit: add isFlexible, minLettableSqm, offeredSqm fields
- New UnitBundle + UnitNeedMatch domain types
- unitMatchService: match individual units or bundles against LatentNeeds
(exact, partial/Teilfläche, bundle match types with area scoring)
- Mock units updated: prop-001/007/012/013 free units flagged as flexible
with minimum lettable areas
- UnitStructurePanel replaces old Stockwerkstruktur table:
- Checkboxes on free units (when ≥2 exist) for bundle selection
- Inline match pills per free unit (top need + score)
- Expand row to see all matching needs with area details
- 'Teilfläche möglich' chip for flexible units with min sqm
- Bundle panel appears when 2+ units selected: shows combined sqm,
bundle matches, and note about remaining area after contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import type { Property, PropertyUnit, UnitBundle, UnitNeedMatch } from '../domain/property'
|
||||
import { mockLatentNeeds } from '../mock-data/latentNeeds'
|
||||
|
||||
// How many m² of tolerance above need.max we still consider a match (e.g. 20%)
|
||||
const OVERSIZE_TOLERANCE = 0.25
|
||||
|
||||
function areaScore(unitSqm: number, min: number, max: number): number {
|
||||
const effectiveMax = max * (1 + OVERSIZE_TOLERANCE)
|
||||
if (unitSqm < min || unitSqm > effectiveMax) return 0
|
||||
// Perfect score when unit size is in the middle of the range
|
||||
const mid = (min + max) / 2
|
||||
const spread = max - min
|
||||
const dist = Math.abs(unitSqm - mid)
|
||||
return Math.max(0.5, 1 - dist / (spread + 1))
|
||||
}
|
||||
|
||||
function assetTypeMatch(property: Property, needAssetType: string): number {
|
||||
if (property.assetType === needAssetType) return 1
|
||||
// Mixed-use partial credit
|
||||
if (String(property.assetType).toUpperCase() === 'MIXED') return 0.7
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Find needs that a single unit can satisfy (full or partial) */
|
||||
export const unitMatchService = {
|
||||
getMatchesForUnit(
|
||||
unit: PropertyUnit,
|
||||
property: Property,
|
||||
): UnitNeedMatch[] {
|
||||
const results: UnitNeedMatch[] = []
|
||||
|
||||
for (const need of mockLatentNeeds) {
|
||||
const { min, max } = need.sizeRange
|
||||
const atMatch = assetTypeMatch(property, String(need.assetType))
|
||||
if (atMatch === 0) continue
|
||||
|
||||
const offeredSqm = unit.offeredSqm ?? unit.areaSqm
|
||||
|
||||
// Full unit fits the need
|
||||
const fullScore = areaScore(offeredSqm, min, max)
|
||||
if (fullScore > 0) {
|
||||
results.push({
|
||||
needId: need.id,
|
||||
tenantName: need.tenantCompany ?? need.title,
|
||||
tenantCompany: need.tenantCompany,
|
||||
requiredSqmMin: min,
|
||||
requiredSqmMax: max,
|
||||
matchScore: Math.round(fullScore * atMatch * 95),
|
||||
matchType: 'exact',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Partial: unit is larger than need.max but unit is flexible — offer a slice
|
||||
if (unit.isFlexible && offeredSqm > max && (unit.minLettableSqm ?? 0) <= max) {
|
||||
const partialSqm = Math.min(offeredSqm, max)
|
||||
const partialScore = areaScore(partialSqm, min, max)
|
||||
if (partialScore > 0) {
|
||||
results.push({
|
||||
needId: need.id,
|
||||
tenantName: need.tenantCompany ?? need.title,
|
||||
tenantCompany: need.tenantCompany,
|
||||
requiredSqmMin: min,
|
||||
requiredSqmMax: max,
|
||||
matchScore: Math.round(partialScore * atMatch * 85),
|
||||
matchType: 'partial',
|
||||
suggestedSqm: partialSqm,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.matchScore - a.matchScore)
|
||||
.slice(0, 3)
|
||||
},
|
||||
|
||||
/** Find needs that a bundle of units can satisfy together */
|
||||
getMatchesForBundle(
|
||||
units: PropertyUnit[],
|
||||
property: Property,
|
||||
): UnitNeedMatch[] {
|
||||
const combinedSqm = units.reduce((sum, u) => sum + (u.offeredSqm ?? u.areaSqm), 0)
|
||||
const results: UnitNeedMatch[] = []
|
||||
|
||||
for (const need of mockLatentNeeds) {
|
||||
const { min, max } = need.sizeRange
|
||||
const atMatch = assetTypeMatch(property, String(need.assetType))
|
||||
if (atMatch === 0) continue
|
||||
|
||||
const score = areaScore(combinedSqm, min, max)
|
||||
if (score > 0) {
|
||||
results.push({
|
||||
needId: need.id,
|
||||
tenantName: need.tenantCompany ?? need.title,
|
||||
tenantCompany: need.tenantCompany,
|
||||
requiredSqmMin: min,
|
||||
requiredSqmMax: max,
|
||||
matchScore: Math.round(score * atMatch * 90),
|
||||
matchType: 'bundle',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.matchScore - a.matchScore)
|
||||
.slice(0, 3)
|
||||
},
|
||||
|
||||
/** Build a UnitBundle descriptor from selected units */
|
||||
buildBundle(units: PropertyUnit[]): UnitBundle {
|
||||
const floorLabel = (u: PropertyUnit) => {
|
||||
if (u.floorLevel === 0) return 'EG'
|
||||
if (u.floorLevel < 0) return `UG${Math.abs(u.floorLevel)}`
|
||||
return `${u.floorLevel}.OG`
|
||||
}
|
||||
return {
|
||||
unitIds: units.map(u => u.id),
|
||||
combinedSqm: units.reduce((s, u) => s + (u.offeredSqm ?? u.areaSqm), 0),
|
||||
label: units.map(u => `${floorLabel(u)}${u.unitLabel ? ' ' + u.unitLabel : ''}`).join(' + '),
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user