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>
This commit is contained in:
@@ -1,5 +1,133 @@
|
||||
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 2–14 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> {
|
||||
@@ -31,4 +159,9 @@ export const dataQualityService = {
|
||||
topMissingFields,
|
||||
}
|
||||
},
|
||||
|
||||
getMissingCriticalFields,
|
||||
getQualityWarnings,
|
||||
getRecommendedActions,
|
||||
calculatePropertyQuality,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user