Skip to content

Commit d33af1b

Browse files
authored
external-enrichment: Umgebungsfelder nicht mehr bei Refetch-Fehlschlag löschen (#429)
CAMS-Luftqualität, EEA-Lärm und Klimanormalen wurden bei jedem nächtlichen Lauf komplett neu in den locationContext geschrieben; schlug ein Provider transient fehl, fiel das Feld ersatzlos weg statt auf den Vorlauf zurückzufallen — das erzeugte die schwankende Datenabdeckung in den Settings. mergeLocationContextWithPrevious behält jetzt den alten Wert pro Feld bei Fehlschlag. Coverage zeigt zusätzlich pro Quelle das Datum der letzten erfolgreichen Aktualisierung, damit ein dauerhaft toter Provider sichtbar bleibt statt sich hinter alten Daten zu verstecken.
1 parent 9e4a3d8 commit d33af1b

9 files changed

Lines changed: 344 additions & 10 deletions

File tree

components/settings/SettingsExternalDataCard.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface ExternalDataSourceCoverage {
5454
total: number
5555
covered: number
5656
byCountry: ExternalDataCoverageCountryRow[]
57+
lastSuccessAt: string | null
5758
}
5859
5960
const { t, te } = useI18n()
@@ -307,6 +308,9 @@ onMounted(async () => {
307308
</span>
308309
</div>
309310
<Progress :model-value="percentOf(coverageFor(source.id)!.covered, coverageFor(source.id)!.total)" />
311+
<p v-if="coverageFor(source.id)!.lastSuccessAt" class="text-xs text-muted-foreground">
312+
{{ $t('settings.externalData.coverage.lastSuccessAt', { at: formatBatchDate(coverageFor(source.id)!.lastSuccessAt) }) }}
313+
</p>
310314
<p v-if="coverageFor(source.id)!.byCountry.length === 0" class="text-xs text-muted-foreground">
311315
{{ $t('settings.externalData.coverage.empty') }}
312316
</p>

i18n/locales/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@
410410
"coverage": {
411411
"title": "Datenabdeckung",
412412
"summary": "{covered} von {total} geocodierten Auktionen ({percent} %)",
413+
"lastSuccessAt": "Zuletzt erfolgreich aktualisiert: {at}",
413414
"empty": "Noch keine geocodierten Auktionen für diese Quelle.",
414415
"loadError": "Datenabdeckung konnte nicht geladen werden."
415416
},

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@
410410
"coverage": {
411411
"title": "Data coverage",
412412
"summary": "{covered} of {total} geocoded auctions ({percent}%)",
413+
"lastSuccessAt": "Last successfully updated: {at}",
413414
"empty": "No geocoded auctions for this source yet.",
414415
"loadError": "Could not load data coverage."
415416
},

server/api/settings/external-data/coverage.get.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,57 @@ describe('GET /api/settings/external-data/coverage', () => {
8888
{ country: 'fr', total: 50, covered: 45 },
8989
])
9090
})
91+
92+
it('takes the most recent lastSuccessAt across countries, only for the three sources it tracks', async () => {
93+
vi.stubGlobal('defineEventHandler', (handler: unknown) => handler)
94+
95+
const { getPool } = await import('~/server/utils/db')
96+
vi.mocked(getPool).mockReturnValue({
97+
query: async () => ({
98+
rows: [
99+
{
100+
country: 'de',
101+
geocoded_total: '10',
102+
cams_air_quality: '5',
103+
cams_air_quality_last_success_at: '2026-08-01T00:00:00.000Z',
104+
open_meteo_climate_normals: '5',
105+
open_meteo_climate_normals_last_success_at: '2026-07-15T00:00:00.000Z',
106+
eea_environmental_noise_directive: '5',
107+
eea_environmental_noise_directive_last_success_at: null,
108+
eu_flood_risk_areas: '5',
109+
copernicus_effis: '5',
110+
fr_dvf_geolocated: '5',
111+
},
112+
{
113+
country: 'fr',
114+
geocoded_total: '10',
115+
cams_air_quality: '5',
116+
cams_air_quality_last_success_at: '2026-08-10T00:00:00.000Z',
117+
open_meteo_climate_normals: '5',
118+
open_meteo_climate_normals_last_success_at: '2026-06-01T00:00:00.000Z',
119+
eea_environmental_noise_directive: '5',
120+
eea_environmental_noise_directive_last_success_at: '2026-08-05T00:00:00.000Z',
121+
eu_flood_risk_areas: '5',
122+
copernicus_effis: '5',
123+
fr_dvf_geolocated: '5',
124+
},
125+
],
126+
}),
127+
} as never)
128+
129+
const handler = (await import('./coverage.get')).default as unknown as () => Promise<{
130+
sources: Array<{ id: string; lastSuccessAt: string | null }>
131+
}>
132+
const { sources } = await handler()
133+
const bySourceId = Object.fromEntries(sources.map((source) => [source.id, source.lastSuccessAt]))
134+
135+
expect(bySourceId['cams-air-quality']).toBe('2026-08-10T00:00:00.000Z')
136+
expect(bySourceId['open-meteo-climate-normals']).toBe('2026-07-15T00:00:00.000Z')
137+
expect(bySourceId['eea-environmental-noise-directive']).toBe('2026-08-05T00:00:00.000Z')
138+
// Hazards/market already fall back to their previous value on failure, so
139+
// their freshness isn't tracked here.
140+
expect(bySourceId['eu-flood-risk-areas']).toBeNull()
141+
expect(bySourceId['copernicus-effis']).toBeNull()
142+
expect(bySourceId['fr-dvf-geolocated']).toBeNull()
143+
})
91144
})

server/tasks/external-enrichment.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ describe('runExternalEnrichment', () => {
530530
platform: 'se-kronofogden',
531531
country: 'se',
532532
externalId: '1',
533-
}))
533+
}), null)
534534
expect(writeLocationEnrichmentCache).toHaveBeenCalledWith({
535535
'se-kronofogden:1': expect.objectContaining({
536536
locationContext,
@@ -697,4 +697,87 @@ describe('withLocationContextEnhancers', () => {
697697
expect(summary.errors).toHaveLength(1)
698698
expect(summary.errors[0]).toContain('second')
699699
})
700+
701+
it('restores air quality, climate normals and noise from the previous run when this run cannot refresh them', async () => {
702+
vi.stubGlobal('defineTask', (def: unknown) => def)
703+
const { withLocationContextEnhancers } = await import('./external-enrichment')
704+
const summary = {
705+
processed: 0,
706+
written: 0,
707+
skippedMissingCoordinates: 0,
708+
marketComparisons: 0,
709+
landValueBaselines: 0,
710+
hazards: 0,
711+
locationContexts: 0,
712+
staleResults: 0,
713+
providerFailures: 0,
714+
errors: [] as string[],
715+
durationMs: 0,
716+
}
717+
const previousContext: LocationContext = {
718+
...locationContext,
719+
environment: {
720+
...locationContext.environment,
721+
airQuality: {
722+
index: 30,
723+
level: 'fair',
724+
particulateMatter10: 12,
725+
particulateMatter25: 8,
726+
nitrogenDioxide: 15,
727+
ozone: 40,
728+
observedAt: '2026-08-01T00:00:00.000Z',
729+
sourceLabel: 'CAMS',
730+
sourceUrl: 'https://example.test/cams',
731+
checkedAt: '2026-08-01T00:00:00.000Z',
732+
},
733+
climateNormals: {
734+
periodStartYear: 1991,
735+
periodEndYear: 2020,
736+
months: [],
737+
sourceLabel: 'Open-Meteo',
738+
sourceUrl: 'https://example.test/climate',
739+
checkedAt: '2026-07-01T00:00:00.000Z',
740+
},
741+
reportedNoise: [{
742+
source: 'road',
743+
indicator: 'lden',
744+
level: 'medium',
745+
bandLabel: '60-64 dB Lden',
746+
minDb: 60,
747+
maxDb: 64,
748+
value: 2,
749+
sourceLayerName: null,
750+
sourceLabel: 'EEA',
751+
sourceUrl: 'https://example.test/eea',
752+
checkedAt: '2026-06-01T00:00:00.000Z',
753+
}],
754+
},
755+
}
756+
// This run's OSM rebuild has none of the previous run's enhancer fields yet.
757+
const freshContext: LocationContext = {
758+
...locationContext,
759+
environment: { ...locationContext.environment, airQuality: null, climateNormals: null, reportedNoise: undefined },
760+
}
761+
const baseAdapter = {
762+
id: 'base',
763+
sourceVersion: 'v1',
764+
supports: () => true,
765+
context: vi.fn(async () => freshContext),
766+
}
767+
const failingEnhancer = {
768+
id: 'cams-air-quality',
769+
sourceVersion: 'v1',
770+
supports: () => true,
771+
enhance: vi.fn(async () => {
772+
throw new Error('rate limited')
773+
}),
774+
}
775+
776+
const adapter = withLocationContextEnhancers(baseAdapter, [failingEnhancer], summary)
777+
const result = await adapter.context(auction(), previousContext)
778+
779+
expect(result?.environment.airQuality).toEqual(previousContext.environment.airQuality)
780+
expect(result?.environment.climateNormals).toEqual(previousContext.environment.climateNormals)
781+
expect(result?.environment.reportedNoise).toEqual(previousContext.environment.reportedNoise)
782+
})
700783
})

server/tasks/external-enrichment.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createEeaEnvironmentalNoiseEnhancer } from '~/server/utils/external-dat
1414
import { createCamsAirQualityEnhancer } from '~/server/utils/external-data/cams-air-quality'
1515
import { createOpenMeteoClimateNormalsEnhancer } from '~/server/utils/external-data/open-meteo-climate'
1616
import { createLocalOsmLocationContextAdapter } from '~/server/utils/external-data/osm-location-context'
17+
import { mergeLocationContextWithPrevious } from '~/server/utils/external-data/location-context-merge'
1718
import {
1819
getStoredExternalDataSourceConfig,
1920
getConfigurableExternalDataSource,
@@ -49,7 +50,7 @@ export interface LocationContextAdapter {
4950
id: string
5051
sourceVersion: string
5152
supports(auction: Auction): boolean
52-
context(auction: Auction): Promise<LocationContext | null>
53+
context(auction: Auction, previous?: LocationContext | null): Promise<LocationContext | null>
5354
}
5455

5556
export interface LocationContextEnhancer {
@@ -175,7 +176,7 @@ export async function runExternalEnrichment(
175176
throwIfTaskAborted(signal)
176177
const hazards = await allHazards(auction, hazardAdapters, summary)
177178
throwIfTaskAborted(signal)
178-
const locationContext = await firstLocationContext(auction, locationContextAdapters, summary)
179+
const locationContext = await firstLocationContext(auction, locationContextAdapters, summary, previous?.locationContext ?? null)
179180
throwIfTaskAborted(signal)
180181

181182
if (marketComparison) summary.marketComparisons++
@@ -315,11 +316,12 @@ async function firstLocationContext(
315316
auction: Auction,
316317
adapters: LocationContextAdapter[],
317318
summary: ExternalEnrichmentSummary,
319+
previous: LocationContext | null,
318320
): Promise<LocationContext | null> {
319321
for (const adapter of adapters) {
320322
if (!adapter.supports(auction)) continue
321323
try {
322-
const result = await adapter.context(auction)
324+
const result = await adapter.context(auction, previous)
323325
if (result) return result
324326
} catch (err) {
325327
recordProviderFailure(summary, adapter.id, 'location context', auction, err)
@@ -477,8 +479,8 @@ export function withLocationContextEnhancers(
477479
id: [adapter.id, ...enhancers.map((enhancer) => enhancer.id)].join('+'),
478480
sourceVersion: [adapter.sourceVersion, ...enhancers.map((enhancer) => enhancer.sourceVersion)].join(','),
479481
supports: (auction) => adapter.supports(auction),
480-
async context(auction) {
481-
let context = await adapter.context(auction)
482+
async context(auction, previous) {
483+
let context = await adapter.context(auction, previous)
482484
if (!context) return null
483485
for (const enhancer of enhancers) {
484486
if (!enhancer.supports(auction, context)) continue
@@ -492,7 +494,7 @@ export function withLocationContextEnhancers(
492494
recordProviderFailure(summary, enhancer.id, 'location context enhancer', auction, err)
493495
}
494496
}
495-
return context
497+
return previous ? mergeLocationContextWithPrevious(context, previous) : context
496498
},
497499
}
498500
}

server/utils/external-data/coverage.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,22 @@ export interface ExternalDataSourceCoverage {
2727
total: number
2828
covered: number
2929
byCountry: ExternalDataCoverageCountryRow[]
30+
/** Most recent `checkedAt` this source actually succeeded at, across every
31+
* auction — null when never tracked or never succeeded. A transient
32+
* fetch failure keeps the previous run's value (external-enrichment.ts's
33+
* mergeLocationContextWithPrevious), so unlike `covered` this only stalls
34+
* when the source has stopped succeeding for every auction, surfacing a
35+
* dead provider that a stable coverage percentage would otherwise hide. */
36+
lastSuccessAt: string | null
3037
}
3138

3239
// One row per country, geocoded_total plus one covered-count column per
3340
// source in COVERAGE_SOURCE_IDS (column name = source id with '-' -> '_').
3441
// A single query grouped by country avoids six-way N+1 scans over
35-
// location_enrichment.
42+
// location_enrichment. lastSuccessAt columns are only meaningful for the
43+
// three sources subject to that transient-failure merge above — hazards and
44+
// market comparison already fall back to their previous whole value on
45+
// failure (external-enrichment.ts), so they're never tracked here.
3646
const COVERAGE_QUERY = `
3747
SELECT
3848
a.country,
@@ -41,14 +51,20 @@ const COVERAGE_QUERY = `
4151
WHERE a.lat IS NOT NULL AND a.lng IS NOT NULL
4252
AND nullif(le.enrichment->'locationContext'->'environment'->'airQuality', 'null'::jsonb) IS NOT NULL
4353
) AS cams_air_quality,
54+
max(le.enrichment->'locationContext'->'environment'->'airQuality'->>'checkedAt') AS cams_air_quality_last_success_at,
4455
count(*) FILTER (
4556
WHERE a.lat IS NOT NULL AND a.lng IS NOT NULL
4657
AND le.enrichment->'locationContext'->'environment'->'climateNormals' IS NOT NULL
4758
) AS open_meteo_climate_normals,
59+
max(le.enrichment->'locationContext'->'environment'->'climateNormals'->>'checkedAt') AS open_meteo_climate_normals_last_success_at,
4860
count(*) FILTER (
4961
WHERE a.lat IS NOT NULL AND a.lng IS NOT NULL
5062
AND jsonb_array_length(coalesce(le.enrichment->'locationContext'->'environment'->'reportedNoise', '[]'::jsonb)) > 0
5163
) AS eea_environmental_noise_directive,
64+
max((
65+
SELECT max(n->>'checkedAt')
66+
FROM jsonb_array_elements(coalesce(le.enrichment->'locationContext'->'environment'->'reportedNoise', '[]'::jsonb)) n
67+
)) AS eea_environmental_noise_directive_last_success_at,
5268
count(*) FILTER (
5369
WHERE a.lat IS NOT NULL AND a.lng IS NOT NULL
5470
AND EXISTS (
@@ -84,13 +100,19 @@ const COVERAGE_COLUMN_BY_SOURCE_ID: Record<CoverageSourceId, string> = {
84100
'fr-dvf-geolocated': 'fr_dvf_geolocated',
85101
}
86102

87-
type CoverageRow = { country: string; geocoded_total: string } & Record<string, string>
103+
const LAST_SUCCESS_COLUMN_BY_SOURCE_ID: Partial<Record<CoverageSourceId, string>> = {
104+
'cams-air-quality': 'cams_air_quality_last_success_at',
105+
'open-meteo-climate-normals': 'open_meteo_climate_normals_last_success_at',
106+
'eea-environmental-noise-directive': 'eea_environmental_noise_directive_last_success_at',
107+
}
108+
109+
type CoverageRow = { country: string; geocoded_total: string } & Record<string, string | null>
88110

89111
export async function computeExternalDataCoverage(db: Pool): Promise<ExternalDataSourceCoverage[]> {
90112
const { rows } = await db.query<CoverageRow>(COVERAGE_QUERY)
91113

92114
const bySource = new Map<CoverageSourceId, ExternalDataSourceCoverage>(
93-
COVERAGE_SOURCE_IDS.map((id) => [id, { id, total: 0, covered: 0, byCountry: [] }]),
115+
COVERAGE_SOURCE_IDS.map((id) => [id, { id, total: 0, covered: 0, byCountry: [], lastSuccessAt: null }]),
94116
)
95117

96118
for (const row of rows) {
@@ -103,6 +125,11 @@ export async function computeExternalDataCoverage(db: Pool): Promise<ExternalDat
103125
entry.total += total
104126
entry.covered += covered
105127
entry.byCountry.push({ country: row.country, total, covered })
128+
const lastSuccessColumn = LAST_SUCCESS_COLUMN_BY_SOURCE_ID[id]
129+
const rowLastSuccessAt = lastSuccessColumn ? row[lastSuccessColumn] : null
130+
if (rowLastSuccessAt && (!entry.lastSuccessAt || rowLastSuccessAt > entry.lastSuccessAt)) {
131+
entry.lastSuccessAt = rowLastSuccessAt
132+
}
106133
}
107134
}
108135

0 commit comments

Comments
 (0)