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
@@ -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', () => {