Skip to content

Commit 79c8a53

Browse files
authored
Auktionen als zusammenhängende Verfahren verknüpfen (#403)
* Auktionen als zusammenhängende Verfahren verknüpfen * fix: address auction relationship review
1 parent 73c8a0e commit 79c8a53

14 files changed

Lines changed: 4621 additions & 2 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import type { RelatedAuction } from '~/server/utils/auction-relationships'
3+
import { Link2 } from 'lucide-vue-next'
4+
5+
const props = defineProps<{ relatedAuctions: RelatedAuction[] }>()
6+
const { t } = useI18n()
7+
8+
const groups = computed(() => {
9+
const byKind = new Map<RelatedAuction['kind'], RelatedAuction[]>()
10+
for (const auction of props.relatedAuctions) {
11+
const group = byKind.get(auction.kind)
12+
if (group) group.push(auction)
13+
else byKind.set(auction.kind, [auction])
14+
}
15+
return ([
16+
['same_proceeding', byKind.get('same_proceeding') ?? []],
17+
['same_address', byKind.get('same_address') ?? []],
18+
] as const).filter(([, auctions]) => auctions.length > 0)
19+
})
20+
21+
function detailPath(auction: RelatedAuction): string {
22+
return `/objekt/${encodeURIComponent(auction.platform)}/${encodeURIComponent(auction.externalId)}`
23+
}
24+
25+
function dateLabel(auction: RelatedAuction): string | null {
26+
return auction.auctionDateText ?? auction.auctionDateIso
27+
}
28+
</script>
29+
30+
<template>
31+
<DetailSectionCard v-if="relatedAuctions.length" :title="t('objektDetail.relatedAuctionsTitle')">
32+
<div class="space-y-5">
33+
<section v-for="[kind, auctions] in groups" :key="kind">
34+
<h3 class="flex items-center gap-2 text-sm font-semibold">
35+
<Link2 class="h-4 w-4 text-primary" />
36+
{{ t(`objektDetail.relatedAuctions.${kind}.title`) }}
37+
</h3>
38+
<p class="mt-1 text-sm text-muted-foreground">
39+
{{ t(`objektDetail.relatedAuctions.${kind}.hint`) }}
40+
</p>
41+
<ul class="mt-3 space-y-2">
42+
<li v-for="auction in auctions" :key="`${auction.platform}:${auction.externalId}`">
43+
<NuxtLink
44+
:to="detailPath(auction)"
45+
class="block rounded-lg border border-border px-3 py-2 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
46+
>
47+
<p class="font-medium leading-snug">{{ auction.address || auction.title || t('objektDetail.untitled') }}</p>
48+
<p class="mt-1 text-xs text-muted-foreground">
49+
{{ auction.authority }} · <span class="font-mono">{{ auction.caseNumber }}</span>
50+
<template v-if="dateLabel(auction)"> · {{ dateLabel(auction) }}</template>
51+
</p>
52+
</NuxtLink>
53+
</li>
54+
</ul>
55+
</section>
56+
</div>
57+
</DetailSectionCard>
58+
</template>

i18n/locales/de.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,17 @@
986986
"translationPendingHint": "Übersetzung läuft",
987987
"translationError": "Die Übersetzung konnte nicht geladen werden.",
988988
"auctionDataTitle": "Auktionsdaten",
989+
"relatedAuctionsTitle": "Zusammenhängende Auktionen",
990+
"relatedAuctions": {
991+
"same_proceeding": {
992+
"title": "Gleiches Verfahren, weitere Quelle",
993+
"hint": "Diese Veröffentlichung beschreibt nach Gericht, Aktenzeichen und Termin dasselbe Verfahren. Dokumente und Angaben der einzelnen Quellen bleiben separat verfügbar."
994+
},
995+
"same_address": {
996+
"title": "Weitere Auktionen an derselben Adresse",
997+
"hint": "Diese Auktionen teilen eine Adresse. Sie können unterschiedliche Wohnungen, Anteile, Lose oder Verfahren betreffen."
998+
}
999+
},
9891000
"propertyDataTitle": "Objektdaten",
9901001
"courtInfoTitle": "Gerichtsinformationen",
9911002
"versteigerungstermin": "Versteigerungstermin",

i18n/locales/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,17 @@
986986
"translationPendingHint": "Translating",
987987
"translationError": "The translation could not be loaded.",
988988
"auctionDataTitle": "Auction data",
989+
"relatedAuctionsTitle": "Related auctions",
990+
"relatedAuctions": {
991+
"same_proceeding": {
992+
"title": "Same proceeding, another source",
993+
"hint": "This publication describes the same proceeding based on court, case number and appointment. Documents and information from each source remain available separately."
994+
},
995+
"same_address": {
996+
"title": "More auctions at the same address",
997+
"hint": "These auctions share an address. They may concern different apartments, shares, lots or proceedings."
998+
}
999+
},
9891000
"propertyDataTitle": "Property data",
9901001
"courtInfoTitle": "Court information",
9911002
"versteigerungstermin": "Auction date",

pages/objekt/[platform]/[id].vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ useHead(() => ({
160160
:planning-notes-translating="planningNotesTranslating"
161161
:parcels-translating="parcelsTranslating"
162162
/>
163+
164+
<AuctionRelatedAuctionsSection :related-auctions="a.relatedAuctions" />
163165
</div>
164166

165167
<CostCalculator v-if="a.country === 'de'" :market-value-eur="a.marketValueEur" :region="a.region" />

server/api/auction/[platform]/[id].get.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const registryMock = vi.hoisted(() => ({
1010

1111
vi.mock('../../../utils/geocode', () => ({ geocodeAddress: vi.fn() }))
1212
vi.mock('../../../utils/auction-record', () => ({ readAuctionRecord: vi.fn() }))
13+
vi.mock('../../../utils/auction-relationships', () => ({ readAuctionRelationships: vi.fn() }))
1314
vi.mock('../../../utils/external-data/location-enrichment', () => ({ readLocationEnrichment: vi.fn() }))
1415
vi.mock('../../../utils/list-cache', () => ({ readMergedListCache: vi.fn() }))
1516
vi.mock('../../../utils/verkehrswert-cache', () => ({
@@ -63,11 +64,13 @@ async function loadHandler() {
6364

6465
const { geocodeAddress } = await import('../../../utils/geocode')
6566
const { readAuctionRecord } = await import('../../../utils/auction-record')
67+
const { readAuctionRelationships } = await import('../../../utils/auction-relationships')
6668
const { readLocationEnrichment } = await import('../../../utils/external-data/location-enrichment')
6769
const { readMergedListCache } = await import('../../../utils/list-cache')
6870
const { getRates } = await import('../../../utils/exchange-rate')
6971

7072
vi.mocked(readAuctionRecord).mockResolvedValue(null)
73+
vi.mocked(readAuctionRelationships).mockResolvedValue([])
7174
vi.mocked(geocodeAddress).mockResolvedValue(null)
7275
vi.mocked(readLocationEnrichment).mockResolvedValue(null)
7376
vi.mocked(readMergedListCache).mockResolvedValue(null)
@@ -135,6 +138,7 @@ describe('/api/auction/:platform/:id location enrichment overlay', () => {
135138
const { readAuctionRecord } = await import('../../../utils/auction-record')
136139
const { geocodeAddress } = await import('../../../utils/geocode')
137140
const { readLocationEnrichment } = await import('../../../utils/external-data/location-enrichment')
141+
const { readAuctionRelationships } = await import('../../../utils/auction-relationships')
138142
const handler = await loadHandler()
139143

140144
vi.mocked(readAuctionRecord).mockResolvedValue({
@@ -145,15 +149,23 @@ describe('/api/auction/:platform/:id location enrichment overlay', () => {
145149
})
146150
vi.mocked(geocodeAddress).mockResolvedValue({ lat: 1, lng: 2, displayName: 'Ignored' } as never)
147151
vi.mocked(readLocationEnrichment).mockResolvedValue(enrichment)
152+
vi.mocked(readAuctionRelationships).mockResolvedValue([{
153+
platform: 'zvbawu', externalId: '1330381', kind: 'same_proceeding', confidence: 'high',
154+
country: 'de', region: 'Baden-Württemberg', authority: 'Biberach', caseNumber: '2 K 15/18',
155+
title: 'Doppelhaushälfte', address: 'Am Annaweiher 17, 17/1, 88447 Warthausen',
156+
auctionDateIso: '2026-10-01T09:00:00.000Z', auctionDateText: '01.10.2026, 09:00 Uhr', marketValueEur: 451000,
157+
}])
148158

149159
await expect(handler({ context: { params: { platform: 'zvg-portal', id: '7265' } } })).resolves.toMatchObject({
150160
platform: 'zvg-portal',
151161
externalId: '7265',
152162
lat: 48.1,
153163
lng: 11.5,
154164
locationEnrichment: enrichment,
165+
relatedAuctions: [expect.objectContaining({ externalId: '1330381', kind: 'same_proceeding' })],
155166
})
156167
expect(readLocationEnrichment).toHaveBeenCalledWith('zvg-portal', '7265')
168+
expect(readAuctionRelationships).toHaveBeenCalledWith('zvg-portal', '7265')
157169
})
158170

159171
it('treats a findOne miss as definitive and skips the region crawl', async () => {

server/api/auction/[platform]/[id].get.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ensureEnabledCountriesLoaded, isCountryEnabled, platforms } from '../..
1111
import { readMergedListCache } from '../../../utils/list-cache'
1212
import { readLocationEnrichment } from '../../../utils/external-data/location-enrichment'
1313
import { readAuctionRecord } from '../../../utils/auction-record'
14+
import { readAuctionRelationships, type RelatedAuction } from '../../../utils/auction-relationships'
1415

1516
const LIVE_MISS_TTL_MS = 60_000
1617
const liveMissCache = new Map<string, number>()
@@ -19,6 +20,7 @@ export interface AuctionDetail extends Auction {
1920
lat: number | null
2021
lng: number | null
2122
locationEnrichment: LocationEnrichment | null
23+
relatedAuctions: RelatedAuction[]
2224
}
2325

2426
function cloneAuction(a: Auction): Auction {
@@ -111,6 +113,9 @@ export default defineEventHandler(async (event): Promise<AuctionDetail> => {
111113
const lat = sourcePoint?.lat ?? point?.lat ?? null
112114
const lng = sourcePoint?.lng ?? point?.lng ?? null
113115
applyDescriptionMarketValue(auction)
114-
const locationEnrichment = await readLocationEnrichment(platform, id)
115-
return { ...auction, lat, lng, locationEnrichment }
116+
const [locationEnrichment, relatedAuctions] = await Promise.all([
117+
readLocationEnrichment(platform, id),
118+
readAuctionRelationships(platform, id),
119+
])
120+
return { ...auction, lat, lng, locationEnrichment, relatedAuctions }
116121
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
CREATE TABLE "auction_relationships" (
2+
"left_platform" text NOT NULL,
3+
"left_external_id" text NOT NULL,
4+
"right_platform" text NOT NULL,
5+
"right_external_id" text NOT NULL,
6+
"kind" text NOT NULL,
7+
"confidence" text NOT NULL,
8+
"source" text DEFAULT 'auto' NOT NULL,
9+
"evidence" jsonb DEFAULT '{}'::jsonb NOT NULL,
10+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
11+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
12+
CONSTRAINT "auction_relationships_left_platform_left_external_id_right_platform_right_external_id_pk" PRIMARY KEY("left_platform","left_external_id","right_platform","right_external_id")
13+
);
14+
--> statement-breakpoint
15+
ALTER TABLE "auction_relationships" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
16+
ALTER TABLE "auction_relationships" ADD CONSTRAINT "fk_auction_relationships_left_auction" FOREIGN KEY ("left_platform","left_external_id") REFERENCES "public"."auctions"("platform","external_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
17+
ALTER TABLE "auction_relationships" ADD CONSTRAINT "fk_auction_relationships_right_auction" FOREIGN KEY ("right_platform","right_external_id") REFERENCES "public"."auctions"("platform","external_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
18+
CREATE INDEX "idx_auction_relationships_left" ON "auction_relationships" USING btree ("left_platform","left_external_id");--> statement-breakpoint
19+
CREATE INDEX "idx_auction_relationships_right" ON "auction_relationships" USING btree ("right_platform","right_external_id");--> statement-breakpoint
20+
ALTER TABLE "auction_relationships" ADD CONSTRAINT "auction_relationships_canonical_pair" CHECK (("left_platform", "left_external_id") < ("right_platform", "right_external_id"));--> statement-breakpoint
21+
WITH current_details AS (
22+
SELECT platform, external_id, address
23+
FROM auction_details
24+
WHERE is_latest = true
25+
), candidates AS (
26+
SELECT a.platform, a.external_id, a.country, lower(trim(a.authority)) AS authority_key,
27+
lower(trim(regexp_replace(a.case_number, E'\\s+', ' ', 'g'))) AS case_raw,
28+
a.auction_date_iso,
29+
regexp_replace(replace(translate(lower(coalesce(d.address, '')), 'äöü', 'aou'), 'ß', 'ss'), '[^[:alnum:]]', '', 'g') AS address_key
30+
FROM auctions a
31+
LEFT JOIN current_details d ON d.platform = a.platform AND d.external_id = a.external_id
32+
), normalized AS (
33+
SELECT *, regexp_match(case_raw, E'^0*([0-9]+)\\s*k\\s*0*([0-9]+)\\s*/\\s*0*([0-9]{2,4})$') AS case_parts
34+
FROM candidates
35+
), keyed AS (
36+
SELECT *, CASE WHEN case_parts IS NULL THEN NULL ELSE
37+
(case_parts[1]::integer)::text || ' k ' || (case_parts[2]::integer)::text || '/' || right(case_parts[3], 2)
38+
END AS case_key
39+
FROM normalized
40+
), edges AS (
41+
SELECT l.platform AS left_platform, l.external_id AS left_external_id,
42+
r.platform AS right_platform, r.external_id AS right_external_id,
43+
'same_proceeding'::text AS kind, 'high'::text AS confidence,
44+
jsonb_build_object('migrationBackfill', true, 'sameAuthority', true, 'sameCaseNumber', true, 'sameAuctionDate', true) AS evidence,
45+
0 AS priority
46+
FROM keyed l
47+
JOIN keyed r ON l.country = r.country AND l.authority_key = r.authority_key
48+
AND l.case_key IS NOT NULL AND l.case_key = r.case_key
49+
AND l.auction_date_iso IS NOT DISTINCT FROM r.auction_date_iso
50+
AND (l.platform, l.external_id) < (r.platform, r.external_id)
51+
UNION ALL
52+
SELECT l.platform, l.external_id, r.platform, r.external_id,
53+
'same_address'::text, 'medium'::text,
54+
jsonb_build_object('migrationBackfill', true, 'sameAddress', true), 1
55+
FROM keyed l
56+
JOIN keyed r ON l.country = r.country AND l.address_key = r.address_key
57+
AND length(l.address_key) >= 8 AND l.address_key ~ '[0-9]'
58+
AND (l.platform, l.external_id) < (r.platform, r.external_id)
59+
), deduplicated AS (
60+
SELECT DISTINCT ON (left_platform, left_external_id, right_platform, right_external_id)
61+
left_platform, left_external_id, right_platform, right_external_id, kind, confidence, evidence
62+
FROM edges
63+
ORDER BY left_platform, left_external_id, right_platform, right_external_id, priority
64+
)
65+
INSERT INTO auction_relationships (
66+
left_platform, left_external_id, right_platform, right_external_id, kind, confidence, source, evidence
67+
)
68+
SELECT left_platform, left_external_id, right_platform, right_external_id, kind, confidence, 'auto', evidence
69+
FROM deduplicated;

0 commit comments

Comments
 (0)