fix: P0 stabilisation — score modifiers, logout cache clear, provider isolation, mutation error feedback
- scoreCalculator: apply dataQuality/confidence modifiers to finalScore (were computed but hardcoded to 0) - authService: call queryClient.clear() on logout to prevent cross-session data leakage - queryClient: extract to src/lib/queryClient.ts singleton so services can access it without circular imports - matchSyncService: new service layer owns match-generation logic; MockupNeedProvider no longer imports other providers directly - hooks (11 files): add onError + German toast feedback to every useMutation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -586,9 +586,17 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
]
|
||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||
|
||||
// ── Final score: purely hard/soft weighted sum + must-have penalty ──────────
|
||||
// ── Modifiers: data quality and confidence adjust the final score ─────────
|
||||
// These are intentionally applied after the hard/soft weighted sum so that
|
||||
// a low-confidence or poor-data property can be penalised without distorting
|
||||
// the individual criterion breakdown.
|
||||
const dataQualityModifier = calcDataQualityModifier(property)
|
||||
const confidenceModifier = calcConfidenceModifier(property)
|
||||
|
||||
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
|
||||
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
||||
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
||||
+ dataQualityModifier + confidenceModifier
|
||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||
|
||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||
@@ -616,8 +624,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
finalScore,
|
||||
hardMatchScore,
|
||||
softFactorScore,
|
||||
dataQualityModifier: 0,
|
||||
confidenceModifier: 0,
|
||||
dataQualityModifier,
|
||||
confidenceModifier,
|
||||
positiveFactors,
|
||||
negativeFactors,
|
||||
allHardFactors: hardFactors,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { aiMonitoringService } from '../services/aiMonitoringService'
|
||||
import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const QK = 'aiOutputs'
|
||||
const STALE = 30_000
|
||||
@@ -33,5 +34,8 @@ export function useUpdateAIOutputReviewStatus() {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { sourceService } from '../services/sourceService'
|
||||
import type { SourceFilters, SourceStatus, TermsStatus } from '../domain/dataSource'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const STALE_SOURCES = 30_000
|
||||
|
||||
@@ -42,6 +43,9 @@ export function useTriggerMockRun() {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-source', sourceId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['connector-runs', sourceId] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Verbindungstest fehlgeschlagen.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,6 +58,9 @@ export function useUpdateSourceStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-sources'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['data-source'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Quellstatus konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,5 +73,8 @@ export function useMarkTermsStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-sources'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['data-source'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('AGB-Status konnte nicht gespeichert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { STALE_SIGNALS } from '../lib/constants'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useFutureSignals() {
|
||||
return useQuery({
|
||||
@@ -39,6 +40,9 @@ export function useVerifySignal() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Signal konnte nicht verifiziert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,5 +55,8 @@ export function useUpdateSignalReviewStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['futureSignal'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { inquiryService } from '../services/inquiryService'
|
||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||
import type { Attachment } from '../domain/inquiry'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useActiveInquiries(filters?: InquiryFilters) {
|
||||
return useQuery({
|
||||
@@ -34,6 +35,9 @@ export function useSendInquiryReply() {
|
||||
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
|
||||
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Antwort konnte nicht gesendet werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { marketIntelligenceService } from '../services/marketIntelligenceService'
|
||||
import { reviewService } from '../services/reviewService'
|
||||
import type { MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const STALE_SIGNALS = 30_000
|
||||
|
||||
@@ -33,6 +34,9 @@ export function useUpdateSignalStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signal'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Signal-Status konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +48,9 @@ export function useConvertToFutureSignal() {
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signal'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Konvertierung zu Future Signal fehlgeschlagen.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,11 +70,17 @@ export function useLinkSignalToEntity() {
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signal'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Signal konnte nicht verknüpft werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateReviewTask() {
|
||||
return useMutation({
|
||||
mutationFn: (signalId: string) => reviewService.createReviewTask(signalId),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Überprüfungsaufgabe konnte nicht erstellt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { matchService } from '../services/matchService'
|
||||
import { STALE_MATCHES } from '../lib/constants'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useMatches() {
|
||||
return useQuery({
|
||||
@@ -38,6 +39,9 @@ export function useApproveMatch() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Match konnte nicht genehmigt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { offerService } from '../services/offerService'
|
||||
import type { AssetType } from '../domain/enums'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useCreateOfferDraft() {
|
||||
return useMutation({
|
||||
@@ -20,6 +21,9 @@ export function useCreateOfferDraft() {
|
||||
input.assetType,
|
||||
input.sizeRange,
|
||||
),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Angebot konnte nicht erstellt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,23 +31,35 @@ export function useUpdateOfferField() {
|
||||
return useMutation({
|
||||
mutationFn: (input: { offerDraftId: string; fieldId: string; value: string }) =>
|
||||
offerService.updateOfferField(input.offerDraftId, input.fieldId, input.value),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Feld konnte nicht gespeichert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarkOfferChecked() {
|
||||
return useMutation({
|
||||
mutationFn: (offerDraftId: string) => offerService.markOfferChecked(offerDraftId),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Prüfung konnte nicht gespeichert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGeneratePdfPreview() {
|
||||
return useMutation({
|
||||
mutationFn: (offerDraftId: string) => offerService.generatePdfPreview(offerDraftId),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('PDF-Vorschau konnte nicht generiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSendOffer() {
|
||||
return useMutation({
|
||||
mutationFn: (offerDraftId: string) => offerService.sendOffer(offerDraftId),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Angebot konnte nicht gesendet werden. Bitte versuchen Sie es erneut.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { reminderService } from '../services/reminderService'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useReminders() {
|
||||
return useQuery({
|
||||
@@ -32,6 +33,9 @@ export function useCompleteReminder() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Erinnerung konnte nicht abgeschlossen werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +48,9 @@ export function useDismissReminder() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Erinnerung konnte nicht verworfen werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +63,9 @@ export function useSnoozeReminder() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Erinnerung konnte nicht verschoben werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,5 +78,8 @@ export function useUpdateReminder() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder', id] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Erinnerung konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { reviewService } from '../services/reviewService'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import type { ReviewFilters } from '../provider/IReviewProvider'
|
||||
import type { ReviewTaskStatus } from '../domain/review'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const STALE_REVIEW = 30_000
|
||||
const QK = 'reviewQueue'
|
||||
@@ -37,6 +38,9 @@ export function useUpdateReviewStatus() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,6 +52,9 @@ export function useAssignReviewTask() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Aufgabe konnte nicht zugewiesen werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,6 +69,9 @@ export function useAddReviewNote() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Notiz konnte nicht hinzugefügt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,6 +82,9 @@ export function useApproveReviewItem() {
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
reviewService.approve(id, 'current-user', notes),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Genehmigung fehlgeschlagen.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,5 +94,8 @@ export function useRejectReviewItem() {
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
reviewService.reject(id, 'current-user', notes),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }),
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Ablehnung fehlgeschlagen.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { shortlistService } from '../services/shortlistService'
|
||||
import type { CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useShortlists() {
|
||||
return useQuery({
|
||||
@@ -26,6 +27,9 @@ export function useCreateShortlist() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Merkliste konnte nicht erstellt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,6 +42,9 @@ export function useAddToShortlist() {
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Eintrag konnte nicht zur Merkliste hinzugefügt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,6 +57,9 @@ export function useRemoveFromShortlist() {
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Eintrag konnte nicht von der Merkliste entfernt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,5 +72,8 @@ export function useUpdateShortlist() {
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlists'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['shortlist', id] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Merkliste konnte nicht aktualisiert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { signalPipelineService } from '../services/signalPipelineService'
|
||||
import type { GateType } from '../domain/signalPipeline'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const STALE_PIPELINE = 15_000
|
||||
|
||||
@@ -32,6 +33,9 @@ export function useEvaluateGate() {
|
||||
onSuccess: (_data, { signalId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['signal-pipeline', signalId] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Gate-Evaluierung fehlgeschlagen.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,5 +48,8 @@ export function usePublishToFutureAvailability() {
|
||||
queryClient.invalidateQueries({ queryKey: ['signal-audit-trail', signalId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Veröffentlichung fehlgeschlagen. Bitte versuchen Sie es erneut.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { STALE_PROPERTIES } from './constants'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: STALE_PROPERTIES,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
+2
-11
@@ -2,22 +2,13 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { theme } from './lib/theme'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import { AuthProvider } from './provider/AuthProvider'
|
||||
import { STALE_PROPERTIES } from './lib/constants'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: STALE_PROPERTIES,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,120 +1,10 @@
|
||||
import type { INeedProvider, NeedFilters } from './INeedProvider'
|
||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import { mockNeeds } from '../mock-data/needs'
|
||||
import { matchStore } from './MockupMatchProvider'
|
||||
import { propertyStore } from './MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
import type { MatchEngineOutput } from '../domain/scoring'
|
||||
import type { Property } from '../domain/property'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||
import { generateMatchesForNeed, syncMatchesForNeed } from '../services/matchSyncService'
|
||||
|
||||
const store: Need[] = [...mockNeeds]
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function strengthFromScore(s: number): MatchStrength {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
if (s >= 55) return MatchStrength.MODERATE
|
||||
return MatchStrength.WEAK
|
||||
}
|
||||
|
||||
function buildMatch(
|
||||
prop: Property,
|
||||
unitId: string | undefined,
|
||||
need: Need,
|
||||
output: MatchEngineOutput,
|
||||
effectiveResultType: string,
|
||||
resultId: string,
|
||||
now: string,
|
||||
): Match {
|
||||
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
|
||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
unitId,
|
||||
needId: need.id,
|
||||
resultId,
|
||||
resultType: effectiveResultType as Match['resultType'],
|
||||
matchScore: output.finalScore,
|
||||
matchStrength: strengthFromScore(output.finalScore),
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: output.hardMatchScore,
|
||||
softFactorScore: output.softFactorScore,
|
||||
confidenceModifier: output.confidenceModifier,
|
||||
dataQualityModifier: output.dataQualityModifier,
|
||||
totalScore: output.finalScore,
|
||||
},
|
||||
positiveFactors: output.positiveFactors,
|
||||
negativeFactors: output.negativeFactors,
|
||||
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
|
||||
mustHaveEvaluation: output.mustHaveEvaluation,
|
||||
tradeoffs: output.tradeOffs ?? [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
|
||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function scoreProperty(need: Need, prop: Property, overrideArea?: number, overridePrice?: number, overrideResultType?: string): MatchEngineOutput {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
||||
return calculateScore(need, {
|
||||
...prop,
|
||||
areaSqm: overrideArea ?? prop.areaSqm,
|
||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||
})
|
||||
}
|
||||
return calculateScore(need, prop)
|
||||
}
|
||||
|
||||
function generateSyntheticMatches(need: Need) {
|
||||
const now = new Date().toISOString()
|
||||
const MIN_SCORE = 22
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||
|
||||
if (hasExplicitUnits) {
|
||||
// Whole-property match (multi-unit building)
|
||||
const output = scoreProperty(need, prop)
|
||||
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
||||
}
|
||||
|
||||
// Per released unit (pre-market)
|
||||
for (const unit of prop.units!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||
}
|
||||
} else {
|
||||
// Single-unit / synthesised units
|
||||
for (const unit of getEffectiveUnits(prop)) {
|
||||
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||
const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
|
||||
const effectiveResultType = (isPreMarket || isFutureProp) ? ResultType.FUTURE_AVAILABILITY : (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
|
||||
const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
|
||||
if (output.excluded || output.finalScore < MIN_SCORE) continue
|
||||
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MockupNeedProvider: INeedProvider = {
|
||||
@@ -131,20 +21,15 @@ export const MockupNeedProvider: INeedProvider = {
|
||||
async create(data: CreateNeedInput) {
|
||||
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
store.push(next)
|
||||
generateSyntheticMatches(next)
|
||||
generateMatchesForNeed(next)
|
||||
return next
|
||||
},
|
||||
async update(id, data: UpdateNeedInput) {
|
||||
const idx = store.findIndex(n => n.id === id)
|
||||
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||
// Remove old matches and recompute with updated weights
|
||||
const updated = store[idx]
|
||||
const startLen = matchStore.length
|
||||
for (let i = startLen - 1; i >= 0; i--) {
|
||||
if (matchStore[i].needId === id) matchStore.splice(i, 1)
|
||||
}
|
||||
generateSyntheticMatches(updated)
|
||||
return updated
|
||||
syncMatchesForNeed(store[idx])
|
||||
return store[idx]
|
||||
},
|
||||
async remove(id) {
|
||||
const idx = store.findIndex(n => n.id === id)
|
||||
@@ -154,5 +39,5 @@ export const MockupNeedProvider: INeedProvider = {
|
||||
|
||||
// Compute matches for all pre-existing needs so scores reflect their weightingProfile
|
||||
for (const need of store) {
|
||||
generateSyntheticMatches(need)
|
||||
generateMatchesForNeed(need)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ItemResponse } from './types'
|
||||
import { UserRole, WorkspaceType } from '../domain/enums'
|
||||
import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions'
|
||||
import type { Permission } from '../lib/permissions'
|
||||
import { queryClient } from '../lib/queryClient'
|
||||
|
||||
// Mock organizations for org switching
|
||||
const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [
|
||||
@@ -99,6 +100,9 @@ export const authService = {
|
||||
|
||||
async logout(): Promise<ItemResponse<void>> {
|
||||
useSessionStore.getState().logout()
|
||||
// Clear all cached query data so stale data from the previous session
|
||||
// cannot leak to the next user who logs in on the same browser tab.
|
||||
queryClient.clear()
|
||||
return { data: undefined }
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Coordinates match generation between NeedProvider, PropertyProvider, and
|
||||
* MatchProvider. Lives in the service layer so no provider needs to import
|
||||
* another provider directly.
|
||||
*/
|
||||
import { matchStore } from '../provider/MockupMatchProvider'
|
||||
import { propertyStore } from '../provider/MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
import type { MatchEngineOutput } from '../domain/scoring'
|
||||
import type { Need } from '../domain/need'
|
||||
import type { Property } from '../domain/property'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||
|
||||
function strengthFromScore(s: number): MatchStrength {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
if (s >= 55) return MatchStrength.MODERATE
|
||||
return MatchStrength.WEAK
|
||||
}
|
||||
|
||||
function buildMatch(
|
||||
prop: Property,
|
||||
unitId: string | undefined,
|
||||
need: Need,
|
||||
output: MatchEngineOutput,
|
||||
effectiveResultType: string,
|
||||
resultId: string,
|
||||
now: string,
|
||||
): Match {
|
||||
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
|
||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
unitId,
|
||||
needId: need.id,
|
||||
resultId,
|
||||
resultType: effectiveResultType as Match['resultType'],
|
||||
matchScore: output.finalScore,
|
||||
matchStrength: strengthFromScore(output.finalScore),
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: output.hardMatchScore,
|
||||
softFactorScore: output.softFactorScore,
|
||||
confidenceModifier: output.confidenceModifier,
|
||||
dataQualityModifier: output.dataQualityModifier,
|
||||
totalScore: output.finalScore,
|
||||
},
|
||||
positiveFactors: output.positiveFactors,
|
||||
negativeFactors: output.negativeFactors,
|
||||
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
|
||||
mustHaveEvaluation: output.mustHaveEvaluation,
|
||||
tradeoffs: output.tradeOffs ?? [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
|
||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function scoreProperty(
|
||||
need: Need,
|
||||
prop: Property,
|
||||
overrideArea?: number,
|
||||
overridePrice?: number,
|
||||
overrideResultType?: string,
|
||||
): MatchEngineOutput {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
||||
return calculateScore(need, {
|
||||
...prop,
|
||||
areaSqm: overrideArea ?? prop.areaSqm,
|
||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||
})
|
||||
}
|
||||
return calculateScore(need, prop)
|
||||
}
|
||||
|
||||
/** Generate matches for a single need against all properties and push them into matchStore. */
|
||||
export function generateMatchesForNeed(need: Need): void {
|
||||
const now = new Date().toISOString()
|
||||
const MIN_SCORE = 22
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||
|
||||
if (hasExplicitUnits) {
|
||||
const output = scoreProperty(need, prop)
|
||||
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
||||
}
|
||||
for (const unit of prop.units!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||
}
|
||||
} else {
|
||||
for (const unit of getEffectiveUnits(prop)) {
|
||||
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||
const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
|
||||
const effectiveResultType = (isPreMarket || isFutureProp)
|
||||
? ResultType.FUTURE_AVAILABILITY
|
||||
: (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
|
||||
const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
|
||||
if (output.excluded || output.finalScore < MIN_SCORE) continue
|
||||
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all existing matches for a need and regenerate them (used after a need update). */
|
||||
export function syncMatchesForNeed(need: Need): void {
|
||||
for (let i = matchStore.length - 1; i >= 0; i--) {
|
||||
if (matchStore[i].needId === need.id) matchStore.splice(i, 1)
|
||||
}
|
||||
generateMatchesForNeed(need)
|
||||
}
|
||||
Reference in New Issue
Block a user