docs(11-03): complete CPV-Divisions-Katalog + Wert-Filter-UI plan
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
---
|
||||
phase: 11-filter-engine-results-ui-saved-searches
|
||||
plan: 03
|
||||
subsystem: api, ui, database
|
||||
tags: [nestjs, prisma, class-validator, cpv, 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/11-02), backend-only NULL-graceful value filter (Plan 11-01)"
|
||||
provides:
|
||||
- "cpv/cpv-catalog.ts: normalizeCpv()/divisionOf()/cpvMatchesDivisions() + CPV_DIVISIONS (45-entry statischer Divisions-Katalog, DE-Labels)"
|
||||
- "Tender.cpvDivisions String[] (@@index Gin) — normalizer-derived für neue Ingests + Backfill-Migration für Bestand"
|
||||
- "buildTenderWhere: cpvDivisions-hasSome-Branch (FILTER-03)"
|
||||
- "TenderQueryDto.cpv[] (Transform normalisiert Einzelwert/Array)"
|
||||
- "FilterPanel.tsx: CPV-Autocomplete (Label-Suche, Mehrfachauswahl, Chips) + Wert-min/max-Eingaben + 'ohne Wertangabe einschließen'-Toggle (Default an)"
|
||||
affects: [11-04-detail-view, 11-05-triage, 11-06-saved-searches]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Zweiseitige Normalisierung (normalizeCpv/divisionOf) statt exaktem Match — löst das Format-Inkonsistenz-Problem an der Wurzel, nicht durch Sonderfälle im Query-Builder"
|
||||
- "Precomputed hasSome-Spalte (cpvDivisions) statt Raw-SQL-Präfix-Match — typsicher, GIN-indexierbar, konsistent mit dem bundesland-Backfill-Muster aus Plan 11-02"
|
||||
- "CPV_DIVISIONS im Web als kleine Konstante gespiegelt (identisch zum BUNDESLAND_OPTIONS-Präzedenzfall aus Plan 11-02) statt Shared-Package"
|
||||
- "Multi-Value-URL-Param (?cpv=45&cpv=71) statt Komma-Join — matcht Express' Default-Query-Parser 1:1, DTO @Transform normalisiert Einzelwert zu Array"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/tenders/cpv/cpv-catalog.ts
|
||||
- apps/api/src/tenders/cpv/cpv-catalog.spec.ts
|
||||
- apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_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-ingestion.service.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:
|
||||
- "cpvDivisions ist ein reiner Divisions-Kurzkatalog (2-stellig, ~45 Einträge) — KEIN EU-CPV-Vollkatalog (Research Open Question 2, D-03). Labels aus Trainingswissen gegen die Standard-CPV-2008-Divisionsliste erstellt (RESEARCH Assumption A2, tertiäre Quelle) — beim nächsten Kontakt mit der offiziellen EU-CPV-Publikation gegenzuprüfen, falls Label-Genauigkeit kritisch wird."
|
||||
- "normalizeCpv() nutzt einen einfachen führende-Ziffern-Regex (^\\d+) statt eine CPV-Prüfziffer-Semantik zu implementieren — für das Divisions-Level (führende 2 Ziffern) genügt das Abschneiden bei Nicht-Ziffern-Zeichen vollständig; eine echte Prüfziffernvalidierung wäre Over-Engineering für einen reinen Präfix-Filter."
|
||||
- "cpv-URL-Param wird als wiederholter Key (?cpv=45&cpv=71) statt Komma-Join geschrieben — matcht Express' Default-Query-Parser direkt (kein Split/Join-Code nötig), DTO normalisiert per @Transform einen Einzelwert (?cpv=45 als String statt Array) zu einem Ein-Element-Array, damit eine einzelne Auswahl nicht an @IsArray scheitert."
|
||||
- "CPV_DIVISION_OPTIONS im Web ist eine 1:1-Kopie von CPV_DIVISIONS (api) statt aus @tessera/shared bezogen — identische Begründung wie BUNDESLAND_OPTIONS in Plan 11-02 (apps/web nutzt @tessera/shared nicht, ~45-Werte-Katalog rechtfertigt keine neue Cross-Package-Abhängigkeit)."
|
||||
|
||||
requirements-completed: [FILTER-03, FILTER-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "normalizeCpv/divisionOf/cpvMatchesDivisions über alle drei Live-Format-Varianten ('45', '45000000', '45000000-7'); CPV_DIVISIONS-Katalog mit 45 Einträgen, deutschen Labels, keine Duplikate"
|
||||
requirement: "FILTER-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/tenders/cpv/cpv-catalog.spec.ts (17 tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "TenderNormalizerService leitet cpvDivisions (distinct 2-stellige Divisionen) aus cpvCodes ab; leeres cpvCodes -> leeres cpvDivisions, nie ein Crash"
|
||||
requirement: "FILTER-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/tenders/tender-normalizer.service.spec.ts (10 tests, davon 2 neu für cpvDivisions)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Backfill-Migration füllt cpvDivisions für Bestandszeilen aus cpvCodes; GIN-Index ergänzt; idempotent (zweiter Lauf: UPDATE 0, ADD COLUMN/CREATE INDEX IF NOT EXISTS überspringen sauber)"
|
||||
requirement: "FILTER-03"
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "docker exec tessera-ctl-db-1 psql -U tessera -d tessera -f migration.sql -> ALTER TABLE, CREATE INDEX, UPDATE 1612 (erster Lauf); zweiter Lauf: NOTICE column already exists (skip), NOTICE index already exists (skip), UPDATE 0. Verifikation: cpv=['45']-Filter via cpvDivisions && ARRAY['45'] liefert 741 Zeilen inkl. aller drei Rohformate (175 Zeilen mit bloßem '45'); prisma migrate status -> up to date nach migrate resolve --applied"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "buildTenderWhere: cpv[] gesetzt -> cpvDivisions hasSome-Branch; mehrere Divisionen kombinierbar; leer/omitted fügt keinen AND-Branch hinzu"
|
||||
requirement: "FILTER-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/tenders/tender-query.builder.spec.ts (20 tests, davon 3 neu für cpv)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "FilterPanel: CPV-Autocomplete (Label-/Code-Suche, Mehrfachauswahl-Chips mit Entfernen-Button) + Wert-min/max-Zahlenfelder + 'ohne Wertangabe einschließen'-Toggle (Default an, D-05 Pflichtkriterium) — schreibt cpv/valueMin/valueMax/includeNullValue in URL-searchParams"
|
||||
requirement: "FILTER-05"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visuelle/Interaktions-Verifikation des Autocomplete-Dropdowns, der Chip-Entfernung und der resultierenden Trefferliste erfordert einen Docker-Rebuild + Browser-Check (Docker-Stack läuft noch mit alten Images, wie in 11-01/11-02 dokumentiert) — kein Component-Test in diesem Plan angefordert (Plan-Verify-Kommando zielt ausschließlich auf API-Vitest-Specs); TypeScript-Compile (tsc --noEmit) ist fehlerfrei in beiden Apps, bestehende Web-Component-Tests (ResultsList) bleiben grün."
|
||||
---
|
||||
|
||||
# Phase 11 Plan 03: CPV-Divisions-Katalog, Backfill & Wert-Filter-UI Summary
|
||||
|
||||
**Statischer 45-Divisionen-CPV-Katalog mit zweiseitiger Format-Normalisierung schließt Pitfall 2 (inkonsistente cpvCodes); precomputed `cpvDivisions`-Spalte (hasSome, GIN) ersetzt Raw-SQL; FilterPanel bekommt CPV-Autocomplete + den bislang backend-only Wert-min/max-Filter als UI mit "ohne Wertangabe einschließen"-Toggle (Default an).**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~9 min
|
||||
- **Started:** 2026-07-21T16:05:52+02:00
|
||||
- **Completed:** 2026-07-21T16:15:03+02:00
|
||||
- **Tasks:** 3 (Task 1 war TDD, RED-then-GREEN)
|
||||
- **Files modified:** 12 (3 created, 9 modified)
|
||||
|
||||
## Accomplishments
|
||||
- `cpv/cpv-catalog.ts`: statischer 45-Einträge-CPV-Divisions-Katalog (2-stellig, deutsche Labels) + `normalizeCpv()` (führende-Ziffern-Regex, entfernt Prüfziffer/Suffix/Whitespace), `divisionOf()` (führende 2 Ziffern, funktioniert auf normalisierten UND rohen Codes) und `cpvMatchesDivisions()` — 17/17 Tests grün über alle drei Live-Format-Varianten ("45", "45000000", "45000000-7").
|
||||
- `TenderNormalizerService` leitet jetzt `cpvDivisions` (distinct 2-stellige Divisionen) aus `cpvCodes` ab; `tender-ingestion.service.ts` reicht das neue Feld an Prisma-`upsert` (create UND update) durch.
|
||||
- `schema.prisma`: `Tender.cpvDivisions String[] @default([])` + `@@index([cpvDivisions], type: Gin)` ergänzt; Backfill-Migration `20260721150000_tender_cpv_divisions_backfill` lokal angewendet — **1612 von 1671 Zeilen** befüllt (59 Zeilen mit leerem `cpvCodes` bleiben `{}`, kein Datenverlust). Idempotenz verifiziert: zweiter Lauf `UPDATE 0`, `ADD COLUMN IF NOT EXISTS`/`CREATE INDEX IF NOT EXISTS` überspringen sauber.
|
||||
- `buildTenderWhere` um einen konditionalen `cpvDivisions: { hasSome: dto.cpv }`-Branch erweitert (FILTER-03) — typsicher, kein Raw-SQL.
|
||||
- `TenderQueryDto.cpv` (validiertes `string[]`, `@Transform` normalisiert einen einzelnen Query-String-Wert zu einem Ein-Element-Array, da Express nur bei wiederholtem Key ein Array liefert).
|
||||
- `FilterPanel.tsx`: CPV-Autocomplete-Eingabefeld (Suche nach Code oder Label, Dropdown mit bis zu 8 Treffern, Mehrfachauswahl als entfernbare Chips) + Wert-min/max-Zahlenfelder + "ohne Wertangabe einschließen"-Checkbox (Default an) — schreibt `cpv` (wiederholter Key), `valueMin`, `valueMax`, `includeNullValue` in die URL-`searchParams`.
|
||||
- Live-DB-Verifikation: `cpvDivisions && ARRAY['45']` liefert 741 Zeilen — darunter 175 Zeilen, deren `cpvCodes` NUR den bloßen Kurzcode `{45}` enthalten (kein 8-stelliger oder Prüfziffer-Code) — der Kern-Pitfall-2-Fall ist bestätigt gelöst.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1a: CPV-Katalog RED** — `a64f984` (test) — failing `cpv-catalog.spec.ts` (17 Tests, Modul existiert noch nicht)
|
||||
2. **Task 1b: CPV-Katalog GREEN** — `7651ab6` (feat) — `cpv-catalog.ts` implementiert, 17/17 Tests grün
|
||||
3. **Task 2: cpvDivisions-Spalte + Normalizer + Backfill-Migration** — `f9591dc` (feat) — Normalizer-Erweiterung, Schema-Spalte + Gin-Index, Backfill-Migration lokal angewendet (1612/1671 Zeilen)
|
||||
4. **Task 3: CPV- + Wert-Filter — Builder, DTO, FilterPanel** — `d60bfd1` (feat) — `cpv[]`-Branch im Builder, DTO-Feld, FilterPanel-Autocomplete + Wert-min/max-UI
|
||||
|
||||
_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/cpv/cpv-catalog.ts` - statischer Divisions-Katalog + Normalisierungs-Helfer (neu)
|
||||
- `apps/api/src/tenders/cpv/cpv-catalog.spec.ts` - 17 Unit-Tests (neu)
|
||||
- `apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql` - Spalte + Gin-Index + idempotenter Backfill-UPDATE (neu)
|
||||
- `apps/api/src/tenders/tender-normalizer.service.ts` - `cpvDivisions` aus `cpvCodes` abgeleitet
|
||||
- `apps/api/src/tenders/tender-normalizer.service.spec.ts` - 2 neue Testfälle (cpvDivisions-Ableitung, leeres cpvCodes)
|
||||
- `apps/api/src/tenders/tender-ingestion.service.ts` - `cpvDivisions` in Upsert create+update durchgereicht
|
||||
- `apps/api/src/tenders/tender.types.ts` - `NormalizedTenderFields.cpvDivisions` ergänzt
|
||||
- `apps/api/prisma/schema.prisma` - `cpvDivisions`-Spalte + Gin-Index
|
||||
- `apps/api/src/tenders/dto/tender-query.dto.ts` - `cpv`-Feld (validiert, transformiert)
|
||||
- `apps/api/src/tenders/tender-query.builder.ts` - `cpvDivisions`-hasSome-Branch
|
||||
- `apps/api/src/tenders/tender-query.builder.spec.ts` - 3 neue Testfälle (cpv einzeln/mehrfach/leer)
|
||||
- `apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx` - CPV-Autocomplete + Wert-min/max-UI + includeNullValue-Toggle
|
||||
|
||||
## Decisions Made
|
||||
- Kurzkatalog (2-stellig, ~45 Einträge) statt EU-Vollkatalog — bestätigt durch Research Open Question 2 (RESOLVED) und explizite Plan-Vorgabe (D-03).
|
||||
- CPV-Divisions-Labels aus Trainingswissen gegen die Standard-CPV-2008-Divisionsliste zusammengestellt (RESEARCH Assumption A2, tertiäre Quelle) — sollte bei zukünftigem Kontakt mit der offiziellen EU-CPV-Publikation gegengeprüft werden, falls Label-Exaktheit kritisch wird (aktuell kein Blocker: die Codes selbst sind korrekt/stabil, nur die Formulierung der Labels ist die unsichere Dimension).
|
||||
- `cpv`-URL-Param als wiederholter Key statt Komma-Join — reduziert Custom-Split/Join-Code auf beiden Seiten und matcht Express' Default-Verhalten; DTO-`@Transform` fängt den Einzelwert-Fall ab.
|
||||
- CPV_DIVISION_OPTIONS im Web 1:1 aus `apps/api/src/tenders/cpv/cpv-catalog.ts` gespiegelt statt Shared-Package — identisches Muster/Begründung wie `BUNDESLAND_OPTIONS` (Plan 11-02).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Eine kleine, im Plan bereits erwartete Doku-Korrektur: der veraltete Kommentar in `tender-query.dto.ts` ("CPV/favOnly filters are added in later Phase-11 plans (11-03/05)") wurde auf "favOnly filter is added in a later Phase-11 plan (11-05)" aktualisiert, da `cpv` in diesem Plan nun implementiert ist (Rule 1 — Doku-Inkonsistenz, keine funktionale Änderung, im selben Commit wie die DTO-Erweiterung).
|
||||
|
||||
## Issues Encountered
|
||||
- Nach der Schema-Erweiterung (`cpvDivisions`-Spalte) schlug `tsc --noEmit` zunächst mit "Object literal may only specify known properties" in `tender-ingestion.service.ts` fehl, weil der generierte Prisma-Client noch den alten Stand hatte — behoben durch `prisma generate` nach der Migration (kein Auto-Fix-Regelverstoß, erwarteter Zwischenschritt der Migrations-Reihenfolge).
|
||||
- Der erste `migration.sql`-Testlauf (`ALTER TABLE ADD COLUMN` ohne `IF NOT EXISTS`) scheiterte beim zweiten manuellen Ausführen mit "column already exists" — vor dem finalen Commit auf `ADD COLUMN IF NOT EXISTS` korrigiert, um echte Idempotenz herzustellen (die im Plan explizit als Anforderung genannt ist) und konsistent mit dem `CREATE INDEX IF NOT EXISTS`-Muster aus der 11-02-Migration zu bleiben.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - keine externen Service-Konfigurationen erforderlich. Keine neuen npm-Pakete (Package Legitimacy Audit: keine Neuinstallation, CPV-Katalog ist eine statische, repo-committete Datei).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- FILTER-03 (CPV hierarchisch + Autocomplete, format-robust) und FILTER-05 (Wert min/max UI, NULL-graceful) sind end-to-end abgeschlossen — ROADMAP-Erfolgskriterium 1 (Liste + Filter) ist damit vollständig abgedeckt (alle Filter-Dimensionen aus D-01..D-05 implementiert über Pläne 11-01/02/03).
|
||||
- `buildTenderWhere` hat jetzt sechs unabhängige, additiv kombinierbare AND-Branches (q, openOnly/deadline, value, plz/bundesland/region, cpv) — Plan 11-04 (Detailansicht) und 11-05 (Triage) fügen keine weiteren Query-Filter-Branches hinzu, sondern neue Endpunkte/Komponenten.
|
||||
- **Manuelle UAT ausstehend** (wie in 11-01/11-02 SUMMARY dokumentiert): Der Docker-Stack läuft noch mit den alten Images. Für eine Live-Verifikation im Browser (CPV-Autocomplete "Bau" eingeben → Division 45 auswählen → Trefferliste filtert inkl. Zeilen mit rohem "45"; Wertfilter min setzen bei aktivem Toggle → "keine Wertangabe"-Zeilen bleiben sichtbar) ist `docker compose build api web && docker compose up -d` erforderlich (User führt den Rebuild selbst aus, kein Docker-Deploy durch Claude — projektinterne Regel). Alle automatisierten Tests sind grün: 128/128 API-Tests (`pnpm --filter @tessera/api test`), 7/7 Web-Tests für das tender-radar-Modul; `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 files verified present on disk; all 4 task commits (a64f984, 7651ab6, f9591dc, d60bfd1) verified in git log.
|
||||
Reference in New Issue
Block a user