feat(11-03): cpvDivisions column — normalizer + backfill migration

Adds Tender.cpvDivisions String[] (@@index Gin) derived at ingestion time
via cpv-catalog.ts's divisionOf() — replaces exact-match cpvCodes
comparison with a typesafe, GIN-indexable hasSome target (FILTER-03,
Pitfall 2). Backfill migration 20260721150000_tender_cpv_divisions_backfill
applied locally: 1612/1671 rows populated across all observed divisions
(741 rows carry division '45' — Bauarbeiten); idempotent (second run:
UPDATE 0, ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS both skip
cleanly). Applied via docker exec psql + `prisma migrate resolve
--applied` + `prisma generate` against the local dev DB only — no
Docker deploy on the test server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:11:40 +02:00
parent 7651ab6813
commit f9591dc491
6 changed files with 110 additions and 0 deletions
@@ -104,6 +104,7 @@ export class TenderIngestionService {
title: tender.title,
buyerName: tender.buyerName,
cpvCodes: tender.cpvCodes,
cpvDivisions: tender.cpvDivisions,
region: tender.region,
plz: tender.plz,
bundesland: tender.bundesland,
@@ -121,6 +122,7 @@ export class TenderIngestionService {
title: tender.title,
buyerName: tender.buyerName,
cpvCodes: tender.cpvCodes,
cpvDivisions: tender.cpvDivisions,
region: tender.region,
plz: tender.plz,
bundesland: tender.bundesland,
@@ -120,6 +120,42 @@ describe('TenderNormalizerService', () => {
expect(normalized.bundesland).toBeNull();
});
it('derives distinct cpvDivisions (leading 2 digits) from cpvCodes (FILTER-03, Pitfall 2)', () => {
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
const normalized = service.normalize(raw);
expect(Array.isArray(normalized.cpvDivisions)).toBe(true);
for (const division of normalized.cpvDivisions) {
expect(division).toMatch(/^\d{2}$/);
}
// Every cpvCodes entry must normalize into one of the derived divisions.
for (const code of normalized.cpvCodes) {
const digits = code.replace(/\D/g, '');
if (digits.length >= 2) {
expect(normalized.cpvDivisions).toContain(digits.slice(0, 2));
}
}
});
it('cpvDivisions is an empty array when cpvCodes is empty — never crashes', () => {
const raw = loadRawNoticePair(NOTICE_WITH_NO_DEADLINE_OR_VALUE);
const rawWithoutCpv: RawTenderRecord = {
...raw,
ocdsPayload: {
...(raw.ocdsPayload as Record<string, unknown>),
tender: {
...(raw.ocdsPayload as { tender?: Record<string, unknown> })?.tender,
items: [],
},
},
};
const normalized = service.normalize(rawWithoutCpv);
expect(normalized.cpvDivisions).toEqual([]);
});
it('dedupKey = ocid when present', () => {
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { divisionOf } from './cpv/cpv-catalog';
import { bundeslandFromRegion } from './geo/nuts-bundesland';
import type { NormalizedTenderFields, RawTenderRecord } from './tender.types';
@@ -32,6 +33,12 @@ export class TenderNormalizerService {
'Unbenannte Ausschreibung';
const buyerName = textValue(ocdsBuyer?.name);
const cpvCodes = extractCpvCodes(ocdsTender);
// Phase 11 (FILTER-03, Pitfall 2): derive distinct CPV divisions from
// the raw, inconsistently-formatted cpvCodes ("45", "45000000",
// "45000000-7") so new ingests are hasSome-filterable immediately —
// the ~1671 pre-existing rows are backfilled by a one-time migration
// (20260721150000_tender_cpv_divisions_backfill).
const cpvDivisions = deriveCpvDivisions(cpvCodes);
const region = textValue(ocdsBuyer?.address?.region);
const plz = textValue(ocdsBuyer?.address?.postalCode);
// Phase 11 (FILTER-02, Pitfall 1): derive bundesland from region's
@@ -69,6 +76,7 @@ export class TenderNormalizerService {
title,
buyerName,
cpvCodes,
cpvDivisions,
region,
plz,
bundesland,
@@ -221,6 +229,18 @@ function extractCpvCodes(ocdsTender: OcdsTender | undefined): string[] {
return Array.from(new Set(codes));
}
/**
* Distinct CPV division codes (leading 2 digits) derived from the raw
* cpvCodes array via cpv-catalog.ts's divisionOf() — handles mixed formats
* ("45", "45000000", "45000000-7") uniformly (Pitfall 2). Never throws on
* an empty/malformed code; divisionOf('') === '' is filtered out below so
* cpvDivisions never contains an empty-string entry.
*/
function deriveCpvDivisions(cpvCodes: string[]): string[] {
const divisions = cpvCodes.map((code) => divisionOf(code)).filter(Boolean);
return Array.from(new Set(divisions));
}
function parseOcdsDate(value: unknown): Date | null {
if (typeof value !== 'string' || !value) return null;
const parsed = new Date(value);
+9
View File
@@ -55,6 +55,15 @@ export interface NormalizedTenderFields {
title: string;
buyerName: string | null;
cpvCodes: string[];
/**
* Distinct CPV division codes (leading 2 digits) derived from cpvCodes
* via cpv-catalog.ts's normalizeCpv/divisionOf (Phase 11, FILTER-03,
* Pitfall 2). Precomputed at normalization time so the query layer can
* use a typesafe, GIN-indexable `hasSome` match instead of raw-SQL
* per-element prefix matching against the inconsistently-formatted
* cpvCodes array.
*/
cpvDivisions: string[];
/** NUTS region code (e.g. "DEA41") from the buyer address. */
region: string | null;
plz: string | null;