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:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -172,6 +172,38 @@ describe('buildTenderWhere', () => {
|
||||
);
|
||||
expect(hasGeoClause).toBe(false);
|
||||
});
|
||||
|
||||
it('cpv set: adds a cpvDivisions hasSome constraint (FILTER-03, Pitfall 2)', () => {
|
||||
const where = buildTenderWhere(dto({ cpv: ['45'] }));
|
||||
|
||||
expect(where.AND).toEqual(
|
||||
expect.arrayContaining([{ cpvDivisions: { hasSome: ['45'] } }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cpv with multiple divisions: hasSome carries all selected divisions', () => {
|
||||
const where = buildTenderWhere(dto({ cpv: ['45', '71'] }));
|
||||
|
||||
expect(where.AND).toEqual(
|
||||
expect.arrayContaining([{ cpvDivisions: { hasSome: ['45', '71'] } }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cpv omitted or empty is never added to AND', () => {
|
||||
const whereUnset = buildTenderWhere(dto());
|
||||
const whereEmpty = buildTenderWhere(dto({ cpv: [] }));
|
||||
|
||||
for (const where of [whereUnset, whereEmpty]) {
|
||||
const and = (where.AND as unknown[]) ?? [];
|
||||
const hasCpvClause = and.some(
|
||||
(clause) =>
|
||||
typeof clause === 'object' &&
|
||||
clause !== null &&
|
||||
'cpvDivisions' in (clause as Record<string, unknown>),
|
||||
);
|
||||
expect(hasCpvClause).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOrderBy', () => {
|
||||
|
||||
@@ -90,6 +90,14 @@ export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput {
|
||||
AND.push({ region: { startsWith: dto.region } });
|
||||
}
|
||||
|
||||
// FILTER-03 / D-03 (Pitfall 2): CPV-Division-Filter — matched via
|
||||
// `hasSome` against the normalizer/backfill-derived `cpvDivisions`
|
||||
// column (typesafe, GIN-indexable), never an exact-equality match
|
||||
// against the inconsistently-formatted raw `cpvCodes` array.
|
||||
if (dto.cpv?.length) {
|
||||
AND.push({ cpvDivisions: { hasSome: dto.cpv } });
|
||||
}
|
||||
|
||||
if (AND.length) where.AND = AND;
|
||||
return where;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user