Files
property-match/src/services/dataQualityService.ts
T
Benjamin Sutter 1d0cb8b154 feat: F017 data quality system — trust layer across all decision surfaces
New components: DataQualityBadge, DataQualityPanel, DataQualityProgress,
FreshnessIndicator, CriticalFieldWarning, MissingDataList, ProvenancePanel
Service: calculatePropertyQuality, getMissingCriticalFields,
getQualityWarnings, getRecommendedActions with field→action mapping
PropertyDetailView: tab 5/6 now use DataQualityPanel + ProvenancePanel
Datenpflege page: DataQualityBadge, FreshnessIndicator, filter by level,
recommended action column showing highest-priority next step per property

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:51:35 +02:00

168 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import { FreshnessStatus } from '../domain/enums'
import type { DataQualitySummary } from '../domain/dashboard'
import type { DataQuality, Property } from '../domain/property'
export type RecommendedAction = {
id: string
label: string
detail: string
priority: 'HIGH' | 'MEDIUM' | 'LOW'
field?: string
}
// ── Field → Action map ────────────────────────────────────────────────────────
const FIELD_ACTION_MAP: Record<string, Omit<RecommendedAction, 'id'>> = {
'Mietpreis/m²': { label: 'Mietpreis ergänzen', detail: 'Fehlender Mietpreis schließt Objekt aus Budget-Matches aus', priority: 'HIGH', field: 'Mietpreis/m²' },
'Fläche m²': { label: 'Fläche bestätigen', detail: 'Fläche ist Hard-Kriterium für alle Matchings', priority: 'HIGH', field: 'Fläche m²' },
'Verfügbarkeit': { label: 'Verfügbarkeit bestätigen', detail: 'Timing ist entscheidend für Nachfrager mit Deadlines', priority: 'HIGH', field: 'Verfügbarkeit' },
'Adresse': { label: 'Adresse vervollständigen', detail: 'Für Standortbewertung und Kartenansicht notwendig', priority: 'HIGH', field: 'Adresse' },
'Beschreibung': { label: 'Beschreibung hinzufügen', detail: 'Verbesserter Kontext erhöht Nachfrager-Vertrauen', priority: 'MEDIUM', field: 'Beschreibung' },
'Soft Factors': { label: 'Passantenfrequenz & ESG', detail: 'Soft Factors verbessern Match-Scoring erheblich', priority: 'MEDIUM', field: 'Soft Factors' },
'Ausbaustandard': { label: 'Ausbaustandard angeben', detail: 'SHELL/BASIC/FULL/PREMIUM beeinflusst Eignung stark', priority: 'MEDIUM', field: 'Ausbaustandard' },
'Bilder': { label: 'Bilder hochladen', detail: 'Objektfotos steigern Anfragerate deutlich', priority: 'MEDIUM', field: 'Bilder' },
'Jahresmiete (CHF)': { label: 'Jahresmiete angeben', detail: 'Ergänzt Mietpreis/m² für Budgetvergleiche', priority: 'LOW', field: 'Jahresmiete (CHF)' },
'Expansionspotenzial': { label: 'Erweiterungsfläche angeben', detail: 'Wichtig für wachsende Unternehmen', priority: 'LOW', field: 'Expansionspotenzial' },
}
const FRESHNESS_ACTIONS: Record<string, RecommendedAction> = {
[FreshnessStatus.OUTDATED]: {
id: 'review_source',
label: 'Quelle überprüfen',
detail: 'Daten sind älter als 14 Tage — Verfügbarkeit könnte sich geändert haben',
priority: 'HIGH',
},
[FreshnessStatus.STALE]: {
id: 'update_data',
label: 'Daten aktualisieren',
detail: 'Daten sind 214 Tage alt — Aktualitätsscore reduziert',
priority: 'MEDIUM',
},
}
// ── Core Checks ───────────────────────────────────────────────────────────────
const CRITICAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
{ field: 'Mietpreis/m²', present: p => p.rentPricePerSqm > 0 },
{ field: 'Fläche m²', present: p => p.areaSqm > 0 },
{ field: 'Verfügbarkeit', present: p => !!p.availabilityDate },
{ field: 'Adresse', present: p => !!p.address?.street && !!p.address?.city },
]
const OPTIONAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
{ field: 'Beschreibung', present: p => !!p.description && p.description.length > 20 },
{ field: 'Soft Factors', present: p => !!(p.softFactors?.prestigeScore || p.softFactors?.footfallScore || p.softFactors?.commuterAccessScore) },
{ field: 'Ausbaustandard', present: p => !!p.hardFacts?.fitOut },
{ field: 'Bilder', present: p => (p.images?.length ?? 0) > 0 },
{ field: 'Jahresmiete (CHF)', present: p => !!p.rentChfSqmYear },
{ field: 'Expansionspotenzial', present: p => !!(p.expansionPotentialSqm || p.hardFacts) },
]
// ── Public API ────────────────────────────────────────────────────────────────
export function getMissingCriticalFields(property: Property): string[] {
const fromData = property.dataQuality?.missingCriticalFields ?? []
if (fromData.length > 0) return fromData
return CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
}
export function getQualityWarnings(property: Property): string[] {
return property.dataQuality?.warnings ?? []
}
export function getRecommendedActions(quality: DataQuality, freshness?: string): RecommendedAction[] {
const actions: RecommendedAction[] = []
for (const field of quality.missingCriticalFields) {
const def = FIELD_ACTION_MAP[field]
if (def) actions.push({ id: `fill_${field}`, ...def })
}
const fn = freshness ?? quality.freshness
if (fn && fn !== FreshnessStatus.FRESH) {
const freshnessAction = FRESHNESS_ACTIONS[fn]
if (freshnessAction) actions.push(freshnessAction)
}
for (const field of quality.missingOptionalFields) {
const def = FIELD_ACTION_MAP[field]
if (def) actions.push({ id: `fill_opt_${field}`, ...def })
}
return actions
}
export function calculatePropertyQuality(property: Property): DataQuality {
if (property.dataQuality?.qualityLevel) return property.dataQuality
const missingCritical = CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
const missingOptional = OPTIONAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
const completeness = 1 - (missingCritical.length * 0.15 + missingOptional.length * 0.05)
const confidence = property.confidenceScore ?? 0.5
const freshnessVal = property.dataQuality?.freshness ?? FreshnessStatus.OUTDATED
const freshnessFactor = freshnessVal === FreshnessStatus.FRESH ? 1 : freshnessVal === FreshnessStatus.STALE ? 0.7 : 0.4
const score = Math.min(1, Math.max(0, completeness * 0.5 + confidence * 0.3 + freshnessFactor * 0.2))
const warnings: string[] = []
if (confidence < 0.5) warnings.push('Niedrige Daten-Vertrauensscore')
if (freshnessVal === FreshnessStatus.OUTDATED) warnings.push('Daten sind veraltet (>14 Tage)')
if (missingCritical.length > 0) warnings.push(`${missingCritical.length} Pflichtfeld(er) fehlen`)
const qualityLevel = missingCritical.length > 0
? 'INCOMPLETE'
: score >= 0.8 ? 'HIGH' : score >= 0.6 ? 'MEDIUM' : 'LOW'
return {
score,
qualityLevel,
missingCriticalFields: missingCritical,
missingOptionalFields: missingOptional,
lastVerifiedAt: property.dataQuality?.lastVerifiedAt,
freshness: freshnessVal,
warnings,
}
}
// ── Portfolio summary (existing) ──────────────────────────────────────────────
export const dataQualityService = {
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
const properties = await MockupPropertyProvider.getAll()
const avgScoreRaw =
properties.length > 0
? properties.reduce((sum, p) => sum + (p.dataQuality?.score ?? 0), 0) / properties.length
: 0
const fieldCounts = properties
.flatMap(p => p.dataQuality?.missingCriticalFields ?? [])
.reduce<Record<string, number>>((acc, f) => {
acc[f] = (acc[f] ?? 0) + 1
return acc
}, {})
const topMissingFields = Object.entries(fieldCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([f]) => f)
return {
avgScore: Math.round(avgScoreRaw * 100),
critical: properties.filter(p => (p.dataQuality?.score ?? 0) < 0.5).length,
propertiesWithMissingCritical: properties.filter(
p => (p.dataQuality?.missingCriticalFields?.length ?? 0) > 0,
).length,
topMissingFields,
}
},
getMissingCriticalFields,
getQualityWarnings,
getRecommendedActions,
calculatePropertyQuality,
}