docs(11-02): complete region/plz/bundesland filter plan

This commit is contained in:
2026-07-21 16:05:25 +02:00
parent 69e25298c9
commit a4c02116c1
4 changed files with 174 additions and 12 deletions
+2 -2
View File
@@ -27,7 +27,7 @@
### FILTER — Suche & Profile
- [x] **FILTER-01**: Nutzer kann per Volltext-Stichwort (Titel/Beschreibung) suchen.
- [ ] **FILTER-02**: Nutzer kann nach Region / PLZ / Bundesland filtern.
- [x] **FILTER-02**: Nutzer kann nach Region / PLZ / Bundesland filtern.
- [ ] **FILTER-03**: Nutzer kann nach CPV-Code / Branche filtern (hierarchische Auswahl mit Autocomplete).
- [x] **FILTER-04**: Nutzer kann nach Abgabefrist filtern, inkl. Option „nur noch offene".
- [x] **FILTER-05**: Nutzer kann nach geschätztem Auftragswert (min/max) filtern; Ausschreibungen ohne Wertangabe werden korrekt behandelt.
@@ -82,7 +82,7 @@
| SCHEMA-01 | Phase 10 | Complete |
| SCHEMA-02 | Phase 10 | Complete |
| FILTER-01 | Phase 11 | Complete |
| FILTER-02 | Phase 11 | Pending |
| FILTER-02 | Phase 11 | Complete |
| FILTER-03 | Phase 11 | Pending |
| FILTER-04 | Phase 11 | Complete |
| FILTER-05 | Phase 11 | Complete |
+3 -3
View File
@@ -386,10 +386,10 @@ Plans:
4. User can mark a tender as read/unread and as favourite, and filter the results list to only favourites -- both states are per-user, not shared across the tenant
5. The UI clearly indicates data coverage (Oberschwelle vs. Unterschwelle) so an empty or thin result set isn't mistaken for a bug
**Plans**: 1/6 plans executed
**Plans**: 2/6 plans executed
- [x] 11-01-PLAN.md — Trefferliste + Freitext + Sortierung + Coverage-Banner (FILTER-01/04/05, UI-01/05)
- [ ] 11-02-PLAN.md — Bundesland-Ableitung (NUTS + Backfill) + Region/PLZ/Bundesland-Filter (FILTER-02)
- [x] 11-02-PLAN.md — Bundesland-Ableitung (NUTS + Backfill) + Region/PLZ/Bundesland-Filter (FILTER-02)
- [ ] 11-03-PLAN.md — CPV-Filter (Katalog + Divisionen) + Wert-Filter-UI (FILTER-03/05)
- [ ] 11-04-PLAN.md — Detailansicht via ?tender=<id> + Quell-Link (UI-02)
- [ ] 11-05-PLAN.md — Triage: gelesen/ungelesen + Favorit + Merklisten-Filter (UI-03/04)
@@ -462,7 +462,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 8. Dashboard Widgets Vollimplementierung | 4/4 | Complete | 2026-07-01 |
| 9. Cert Manager Module | 6/6 | Complete | 2026-07-02 |
| 10. Ausschreibungs-Radar Foundation & DÖE Ingestion | 6/6 | Complete | 2026-07-21 |
| 11. Filter Engine, Results UI & Saved Searches | 1/6 | In Progress| |
| 11. Filter Engine, Results UI & Saved Searches | 2/6 | In Progress| |
| 12. Tender Notifications | 0/TBD | Not started | - |
| 13. Scraping Adapters & Cross-Source Deduplication | 0/TBD | Not started | - |
| 14. RSS, Email-Alert Ingestion & Module Rollout | 0/TBD | Not started | - |
+10 -7
View File
@@ -5,15 +5,15 @@ milestone_name: Ausschreibungs-Radar
current_phase: 11
current_phase_name: filter-engine-results-ui-saved-searches
status: executing
stopped_at: Completed 11-01-PLAN.md
last_updated: "2026-07-21T13:57:16.027Z"
stopped_at: Completed 11-02-PLAN.md
last_updated: "2026-07-21T14:05:18.391Z"
last_activity: 2026-07-21
last_activity_desc: Phase 11 execution started
progress:
total_phases: 11
completed_phases: 9
total_plans: 52
completed_plans: 46
completed_plans: 47
---
# Project State
@@ -28,11 +28,11 @@ See: .planning/PROJECT.md (updated 2026-07-17)
## Current Position
Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING
Plan: 2 of 6
Plan: 3 of 6
Status: Ready to execute
Last activity: 2026-07-21 — Phase 11 execution started
Progress: [█████████░] 88%
Progress: [█████████░] 90%
## Performance Metrics
@@ -81,6 +81,7 @@ Progress: [█████████░] 88%
| Plan | Duration | Tasks | Files |
|------|----------|-------|-------|
| Phase 11 P01 | 8min | 3 tasks | 11 files |
| Phase 11 P02 | 4min | 3 tasks | 12 files |
## Accumulated Context
@@ -167,6 +168,8 @@ Recent decisions affecting current work:
- [Phase ?]: estimatedValue kommt als String (Prisma Decimal) im JSON-Response — formatValue() im Frontend prüft explizit auf null/NaN statt zu koerzieren
- [Phase ?]: openOnly/includeNullValue Default-Semantik lebt im Builder, nicht im DTO
- [Phase ?]: deadlineFrom/deadlineTo URL-Param-Namen sind 1:1 identisch zu TenderQueryDto-Feldnamen fuer den Saved-Search-Vertrag aus Plan 11-06
- [Phase ?]: bundesland-Filter matcht exakt gegen die befüllte, indexierte Spalte (statt region-startsWith); region bleibt als eigenständiger Präfixfilter erhalten.
- [Phase ?]: BUNDESLAND_OPTIONS im Web als kleine Konstante gespiegelt (kein Shared-Package) — web nutzt @tessera/shared nicht, 16-Werte-Katalog rechtfertigt keine neue Cross-Package-Abhängigkeit.
### Pending Todos
@@ -204,7 +207,7 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-07-21T13:57:16.014Z
Stopped at: Completed 11-01-PLAN.md
Last session: 2026-07-21T14:05:18.378Z
Stopped at: Completed 11-02-PLAN.md
Resume file: None
Last activity: 2026-07-14 - Built LDAP per-user exclude/denylist filter (9d1323f), migration applied on live DB, verified via Playwright: sync deactivated 4 excluded service accounts (administrator/krbtgt/guest/dns-ldap), 2 real LDAP users stay active, 0 wrongly created
@@ -0,0 +1,159 @@
---
phase: 11-filter-engine-results-ui-saved-searches
plan: 02
subsystem: api, ui, database
tags: [nestjs, prisma, class-validator, nuts, nextjs-app-router, vitest]
requires:
- phase: 11-filter-engine-results-ui-saved-searches
provides: "tender-query.builder.ts (buildTenderWhere/buildOrderBy), erweiterter TenderQueryDto, FilterPanel.tsx (Plan 11-01)"
provides:
- "geo/nuts-bundesland.ts: bundeslandFromRegion()/nutsPrefixFor() — NUTS-1-Präfix (DE1..DEG) -> 16 Bundesland-Namen, null-safe"
- "TenderNormalizerService setzt bundesland aus region ab (statt hartcodiert null) — neue Ingests sofort filterbar"
- "Backfill-Migration 20260721140000_tender_bundesland_backfill: 933/1671 Bestandszeilen mit bundesland befüllt + @@index([bundesland])"
- "buildTenderWhere: plz-startsWith, bundesland-exact, region-startsWith Branches"
- "FilterPanel.tsx: PLZ-Feld + 16-Länder-Bundesland-Dropdown, schreibt in URL-searchParams"
affects: [11-03-cpv-filter, 11-04-detail-view, 11-05-triage, 11-06-saved-searches]
tech-stack:
added: []
patterns:
- "Reine, Nest-unabhängige geo/nuts-bundesland.ts-Datei (wie tender-query.builder.ts) — pure Function, isoliert testbar ohne DB"
- "Handgeschriebene idempotente Backfill-Migration (UPDATE ... WHERE col IS NULL) statt destruktivem Rewrite — sicher mehrfach ausführbar"
- "Kleine Konstante im Web gespiegelt (BUNDESLAND_OPTIONS in FilterPanel.tsx) statt Shared-Package für einen stabilen 16-Werte-Katalog"
key-files:
created:
- apps/api/src/tenders/geo/nuts-bundesland.ts
- apps/api/src/tenders/geo/nuts-bundesland.spec.ts
- apps/api/prisma/migrations/20260721140000_tender_bundesland_backfill/migration.sql
modified:
- apps/api/src/tenders/tender-normalizer.service.ts
- apps/api/src/tenders/tender-normalizer.service.spec.ts
- apps/api/src/tenders/tender.types.ts
- apps/api/prisma/schema.prisma
- apps/api/src/tenders/dto/tender-query.dto.ts
- apps/api/src/tenders/tender-query.builder.ts
- apps/api/src/tenders/tender-query.builder.spec.ts
- apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
key-decisions:
- "bundesland-Filter matcht exakt gegen die (jetzt befüllte, indexierte) bundesland-Spalte statt region-startsWith — schneller und robuster nach dem Backfill; region bleibt als eigener, unabhängiger Präfixfilter erhalten (auch für Zeilen ohne bundesland-Derivation nützlich)."
- "BUNDESLAND_OPTIONS wird im Web-Package als kleine Konstante gespiegelt (nicht aus @tessera/shared bezogen) — web nutzt @tessera/shared aktuell gar nicht, ein 16-Werte-Katalog rechtfertigt keine neue Cross-Package-Abhängigkeit (RESEARCH-Vorgabe: 'kleine Konstante spiegeln' explizit als Option genannt)."
- "Migration wurde per docker exec psql lokal angewendet und danach via `prisma migrate resolve --applied` in der _prisma_migrations-Historie nachgezogen (DATABASE_URL musste für Host-Prisma-Aufrufe explizit auf die Container-IP gesetzt werden — .env im Container zeigt intern auf den Service-Namen, vom Host aus nicht auflösbar)."
requirements-completed: [FILTER-02]
coverage:
- id: D1
description: "NUTS-1 (DE1..DEG) -> 16 Bundesland-Namen-Map, null-safe für fehlende/unbekannte region, Reverse-Lookup nutsPrefixFor(), gegen echte DB-Region-Stichproben (DE212/DE300/DE600/DE712/DEA22) validiert"
requirement: "FILTER-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/geo/nuts-bundesland.spec.ts (7 tests)"
status: pass
human_judgment: false
- id: D2
description: "TenderNormalizerService setzt bundesland = bundeslandFromRegion(region) statt hartcodiertem null; region=null -> bundesland=null ohne Crash"
requirement: "FILTER-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-normalizer.service.spec.ts (8 tests, davon 2 neu für bundesland)"
status: pass
human_judgment: false
- id: D3
description: "Backfill-Migration füllt bundesland für Bestandszeilen aus region-NUTS-1-Präfix; @@index([bundesland]) ergänzt; idempotent (zweiter Lauf: UPDATE 0)"
requirement: "FILTER-02"
verification:
- kind: manual_procedural
ref: "docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c \"SELECT bundesland, count(*) FROM \\\"Tender\\\" GROUP BY bundesland\" -> 933/1671 Zeilen über alle 16 Länder befüllt, 738 NULL wo region selbst NULL ist; zweiter Migrationslauf UPDATE 0 (idempotent bestätigt); prisma migrate status -> up to date"
status: pass
human_judgment: false
- id: D4
description: "buildTenderWhere: plz-startsWith, bundesland-exact (gegen befüllte Spalte), region-startsWith Branches — nur angehängt wenn gesetzt"
requirement: "FILTER-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-query.builder.spec.ts (17 tests, davon 4 neu für plz/bundesland/region)"
status: pass
human_judgment: false
- id: D5
description: "FilterPanel erweitert um PLZ-Eingabefeld + 16-Länder-Bundesland-Dropdown, schreibt in URL-searchParams (deep-linkbar, ResultsList/listTenders leiten alle Params bereits transparent weiter)"
requirement: "FILTER-02"
verification: []
human_judgment: true
rationale: "Visuelle/Interaktions-Verifikation des Dropdowns und der resultierenden Trefferliste erfordert einen Docker-Rebuild + Browser-Check (Docker-Stack läuft noch mit alten Images) — kein Component-Test in diesem Plan angefordert; Unit-Tests decken die Builder-Logik ab, nicht das Rendering."
---
# Phase 11 Plan 02: NUTS-Bundesland-Ableitung, Backfill & Region/PLZ/Bundesland-Filter Summary
**NUTS-1-Präfix-Ableitung macht den bis dato konstant-leeren Bundesland-Filter erstmals funktionsfähig: Normalizer-Fix für neue Ingests + Backfill-Migration füllt 933/1671 Bestandszeilen über alle 16 Länder, verdrahtet in Query-Builder + FilterPanel.**
## Performance
- **Duration:** ~4 min
- **Started:** 2026-07-21T15:59:14+02:00
- **Completed:** 2026-07-21T16:03:21+02:00
- **Tasks:** 3 (Task 1 war TDD, RED-then-GREEN)
- **Files modified:** 12 (3 created, 9 modified)
## Accomplishments
- `geo/nuts-bundesland.ts`: feste `NUTS1_BUNDESLAND`-Map (16 Einträge), `bundeslandFromRegion()` (null-safe, case-insensitive Präfix-Match) und `nutsPrefixFor()` (Reverse-Lookup) — gegen 5 echte `region`-Stichproben aus der Live-DB validiert (DE212→Bayern, DE300→Berlin, DE600→Hamburg, DE712→Hessen, DEA22→Nordrhein-Westfalen).
- `TenderNormalizerService.normalize()` setzt `bundesland = bundeslandFromRegion(region)` statt der in Phase 10 hartcodierten `null`-Zuweisung — jede neue Ingestion bekommt sofort ein abgeleitetes Bundesland.
- Handgeschriebene Backfill-Migration `20260721140000_tender_bundesland_backfill`: `CREATE INDEX IF NOT EXISTS "Tender_bundesland_idx"` + idempotenter `UPDATE ... CASE upper(left(region,3)) ... WHERE bundesland IS NULL AND region IS NOT NULL` — lokal angewendet, **933 von 1671 Zeilen** über alle 16 Bundesländer befüllt (738 bleiben NULL, weil `region` selbst NULL ist — kein Datenverlust, kein Crash).
- `schema.prisma`: `@@index([bundesland])` ergänzt für Filter-Performance; `prisma generate` neu ausgeführt.
- `buildTenderWhere` (Query-Builder) um drei konditionale AND-Branches erweitert: `plz` (startsWith), `bundesland` (exakt, gegen die jetzt befüllte indexierte Spalte), `region` (startsWith, unabhängig nutzbar).
- `TenderQueryDto` um validierte `plz`/`region`/`bundesland`-Felder erweitert (`@IsString`, `plz` zusätzlich `@MaxLength(5)`).
- `FilterPanel.tsx`: neues PLZ-Eingabefeld + Bundesland-Dropdown mit den 16 Ländernamen (im Web als kleine Konstante gespiegelt), schreibt in die URL-`searchParams` — `ResultsList`/`listTenders()` leiten sämtliche Params bereits transparent an die API weiter, keine zusätzliche Fetch-Verdrahtung nötig.
## Task Commits
1. **Task 1a: NUTS-Bundesland-Map RED** — `3dec35f` (test) — failing `nuts-bundesland.spec.ts` (7 Tests, Modul existiert noch nicht)
2. **Task 1b: NUTS-Bundesland-Map GREEN** — `d12e1c9` (feat) — `nuts-bundesland.ts` implementiert, 7/7 Tests grün
3. **Task 2: Normalizer + Backfill-Migration** — `e4db602` (feat) — Normalizer-Fix, Schema-Index, Backfill-Migration lokal angewendet + `prisma migrate resolve --applied`
4. **Task 3: Query-Builder + DTO + FilterPanel** — `69e2529` (feat) — plz/bundesland/region-Branches, DTO-Felder, FilterPanel-Erweiterung
_Task 1 war TDD (RED-then-GREEN); Task 2 und 3 waren `type="auto"` ohne separaten RED-Schritt._
## Files Created/Modified
- `apps/api/src/tenders/geo/nuts-bundesland.ts` - NUTS-1→Bundesland-Map + Ableitungs-/Reverse-Funktionen (neu)
- `apps/api/src/tenders/geo/nuts-bundesland.spec.ts` - 7 Unit-Tests (neu)
- `apps/api/prisma/migrations/20260721140000_tender_bundesland_backfill/migration.sql` - Index + idempotenter Backfill-UPDATE (neu)
- `apps/api/src/tenders/tender-normalizer.service.ts` - `bundesland` aus `region` abgeleitet statt hartcodiert `null`
- `apps/api/src/tenders/tender-normalizer.service.spec.ts` - 2 neue Testfälle (region→bundesland, region=null→bundesland=null)
- `apps/api/src/tenders/tender.types.ts` - Dokumentationskommentar für `bundesland`/`region` aktualisiert (nicht mehr "deferred")
- `apps/api/prisma/schema.prisma` - `@@index([bundesland])` ergänzt
- `apps/api/src/tenders/dto/tender-query.dto.ts` - `plz`/`region`/`bundesland` validierte Felder ergänzt
- `apps/api/src/tenders/tender-query.builder.ts` - drei neue konditionale `AND`-Branches
- `apps/api/src/tenders/tender-query.builder.spec.ts` - 4 neue Testfälle (plz/bundesland/region gesetzt + alle drei omitted wenn leer)
- `apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx` - PLZ-Feld + Bundesland-Dropdown (16 Länder)
## Decisions Made
- `bundesland`-Filter matcht exakt gegen die jetzt befüllte, indexierte `bundesland`-Spalte — schneller als `region`-Präfix-Matching und die vom Plan empfohlene Post-Backfill-Variante; `region`-Präfixfilter bleibt als eigenständiger, von `bundesland` unabhängiger Branch erhalten (nützlich für Zeilen, deren `bundesland`-Ableitung z. B. wegen eines unbekannten Präfixes leer blieb).
- `BUNDESLAND_OPTIONS` wird im Web-Package als kleine, dokumentierte Konstante gespiegelt statt über ein Shared-Package bezogen — `apps/web` nutzt `@tessera/shared` aktuell gar nicht, und ein stabiler 16-Werte-EU-Katalog rechtfertigt keine neue Cross-Package-Abhängigkeit (RESEARCH nennt "kleine Konstante spiegeln" explizit als gleichwertige Option).
- Migration lokal per `docker exec tessera-ctl-db-1 psql -U tessera -d tessera` angewendet (kein Host-Port auf dem DB-Container) und danach via `prisma migrate resolve --applied` in die `_prisma_migrations`-Historie nachgezogen, damit `prisma migrate status` konsistent bleibt — `DATABASE_URL` musste für den Host-Prisma-CLI-Aufruf explizit auf die Container-IP (`172.19.0.2`) gesetzt werden, da der im API-Container gültige `.env`-Hostname vom Host aus nicht auflösbar ist.
## Deviations from Plan
None - plan executed exactly as written. Eine kleine Ergänzung: der veraltete "Deferred to Phase 11"-Dokumentationskommentar in `tender.types.ts` wurde konsistent mit der neuen Implementierung aktualisiert (Rule 1 — Bug/Doku-Inkonsistenz, keine funktionale Änderung, im selben Commit wie der Normalizer-Fix).
## Issues Encountered
- `prisma migrate status`/`prisma migrate resolve` schlugen zunächst mit "Environment variable not found: DATABASE_URL" fehl, weil das Host-Shell-Environment die im Container gültige `.env` nicht automatisch lädt bzw. der dortige Hostname vom Host nicht auflösbar ist — gelöst durch expliziten `DATABASE_URL=postgresql://tessera:tessera_dev@172.19.0.2:5432/tessera`-Präfix (MEMORY-Hinweis "Lokale DB-Migrationen").
## User Setup Required
None - keine externen Service-Konfigurationen erforderlich. Keine neuen npm-Pakete.
## Next Phase Readiness
- Bundesland-Filter liefert jetzt echte Treffer (933/1671 Zeilen über alle 16 Länder) statt konstant 0 — Kern-Pitfall des Plans behoben.
- `plz`/`region`/`bundesland` sind als AND-Branches im Builder etabliert; Plan 11-03 (CPV) fügt einen weiteren unabhängigen Branch nach demselben Muster hinzu, ohne bestehende Branches zu ändern.
- **Manuelle UAT ausstehend** (wie in 11-01 SUMMARY dokumentiert): Der Docker-Stack läuft noch mit den alten Images. Für eine Live-Verifikation im Browser (Bundesland-Dropdown wählen → Trefferliste filtert; PLZ-Präfix eintragen → Trefferliste filtert) ist `docker compose build api web && docker compose up -d` erforderlich (User führt den Rebuild selbst aus). Alle automatisierten Tests sind grün: 106/106 API-Tests (`pnpm --filter @tessera/api test`), 114/114 Web-Tests (`pnpm --filter @tessera/web test`); `tsc --noEmit` fehlerfrei in beiden Apps.
---
*Phase: 11-filter-engine-results-ui-saved-searches*
*Completed: 2026-07-21*
## Self-Check: PASSED
All 10 created/modified source files verified present on disk; all 4 task commits (3dec35f, d12e1c9, e4db602, 69e2529) verified in git log.