feat(11-03): CPV-Filter + Wert-min/max-UI in Builder, DTO, FilterPanel

TenderQueryDto gains a validated cpv[] field (single-or-repeated query
param, normalized via @Transform); buildTenderWhere adds a cpvDivisions
hasSome branch (FILTER-03, Pitfall 2 — never an exact match against raw
cpvCodes). FilterPanel gets a CPV-Division autocomplete (search-by-label,
multi-select chips, repeated ?cpv= params) plus the previously
backend-only value filter's UI: valueMin/valueMax number inputs and an
"ohne Wertangabe einschließen" toggle (default on, matches the builder's
includeNullValue default from Plan 11-01) so the 91.6% NULL-value rows
stay visible by default. Verified against the live DB: cpv=45 matches all
three raw formats ("45", "45000000", "45000000-7") via the backfilled
cpvDivisions column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:14:51 +02:00
parent f9591dc491
commit d60bfd1966
4 changed files with 261 additions and 5 deletions
+21 -3
View File
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDate,
IsIn,
@@ -24,8 +25,8 @@ import {
*
* Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=...
*
* CPV/favOnly filters are added in later Phase-11 plans (11-03/05) —
* deliberately NOT added here.
* favOnly filter is added in a later Phase-11 plan (11-05) — deliberately
* NOT added here.
*/
export class TenderQueryDto {
/**
@@ -148,4 +149,21 @@ export class TenderQueryDto {
@IsOptional()
@IsString()
bundesland?: string;
/**
* Ausgewählte CPV-Divisionen (FILTER-03, D-03) — matched against the
* normalizer/backfill-derived `cpvDivisions` column via `hasSome` in the
* builder (Pitfall 2: never an exact match against raw cpvCodes).
* Normalizes a single-value query string (`?cpv=45`) to a one-element
* array — Express's default query parser only produces an array when the
* key is repeated (`?cpv=45&cpv=71`), so a lone selection would otherwise
* fail @IsArray validation.
*/
@IsOptional()
@Transform(({ value }) =>
value === undefined ? value : Array.isArray(value) ? value : [value],
)
@IsArray()
@IsString({ each: true })
cpv?: string[];
}