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
@@ -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);