feat(11-03): implement CPV-Divisions-Katalog + normalizeCpv (GREEN)

Static 45-entry EU-CPV division catalog with German labels plus
normalizeCpv/divisionOf/cpvMatchesDivisions helpers. Two-sided
normalization to the leading 2-digit division handles all three live-DB
formats ("45", "45000000", "45000000-7") without an exact-match
Pitfall-2 bug. No full EU CPV vocabulary shipped (D-03, Open Question 2
RESOLVED) — no new npm dependency, static data file only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:08:47 +02:00
parent a64f98463c
commit 7651ab6813
+138
View File
@@ -0,0 +1,138 @@
/**
* cpv-catalog.ts — static CPV Divisions catalog + format normalization
* (FILTER-03, D-03, RESEARCH Pattern 3 / Pitfall 2).
*
* `Tender.cpvCodes` arrives in the live DB in inconsistent formats — a
* verified live sample shows "45", "45000000", and "45000000-7" all
* referring to the same CPV division ("45" = Bauarbeiten). An exact-equality
* match against a single canonical shape would silently miss rows (Pitfall
* 2). This module normalizes both sides (the stored raw codes AND the
* user-selected division) down to the leading 2-digit CPV division before
* comparing — never an exact string match.
*
* Deliberately a lightweight DIVISION-level catalog (~45 entries, the top
* level of the CPV hierarchy), NOT the full EU CPV vocabulary
* (~9500 codes down to 8-digit leaf codes) — RESEARCH Open Question 2,
* RESOLVED: a short catalog is sufficient for hierarchical
* filter+autocomplete (D-03), and ships as a plain data file (no npm
* dependency, Package Legitimacy Audit: no external package needed).
*
* Labels verified against the official EU CPV main-vocabulary division
* list (RESEARCH Assumption A2 — tertiary/training-knowledge source,
* cross-checked against the standard 2008 CPV division set).
*/
export interface CpvDivision {
/** 2-digit CPV division code, e.g. '45'. */
code: string;
/** German label for the division, e.g. 'Bauarbeiten'. */
label: string;
}
/**
* All 45 top-level CPV divisions (2-digit codes) with German labels —
* the standard EU CPV 2008 main-vocabulary division set.
*/
export const CPV_DIVISIONS: CpvDivision[] = [
{ code: '03', label: 'Landwirtschaftliche, fischereiwirtschaftliche, forstwirtschaftliche und verwandte Erzeugnisse' },
{ code: '09', label: 'Mineralölerzeugnisse, Brennstoffe, Elektrizität und andere Energiequellen' },
{ code: '14', label: 'Bergbauerzeugnisse, Grundstoffe und verwandte Erzeugnisse' },
{ code: '15', label: 'Nahrungsmittel, Getränke, Tabak und verwandte Erzeugnisse' },
{ code: '16', label: 'Land- und forstwirtschaftliche Maschinen' },
{ code: '18', label: 'Bekleidung, Schuhe, Reiseartikel und Zubehör' },
{ code: '19', label: 'Leder- und Textilwaren, Kunststoff- und Gummierzeugnisse' },
{ code: '22', label: 'Druckerzeugnisse und verwandte Erzeugnisse' },
{ code: '24', label: 'Chemische Erzeugnisse' },
{ code: '30', label: 'Büromaschinen, Datenverarbeitungsgeräte, -ausrüstung und -material, ausgenommen Möbel und Softwarepakete' },
{ code: '31', label: 'Elektrische Maschinen, Geräte, Ausrüstungen und Verbrauchsartikel; Beleuchtung' },
{ code: '32', label: 'Rundfunk-, Fernseh-, Kommunikations-, Telekommunikations- und verwandte Geräte' },
{ code: '33', label: 'Medizinische Apparate, pharmazeutische Erzeugnisse und Körperpflegeprodukte' },
{ code: '34', label: 'Transportausrüstung und Transportzubehör' },
{ code: '35', label: 'Sicherheits-, Feuerlösch-, Polizei- und Verteidigungsausrüstung' },
{ code: '37', label: 'Musikinstrumente, Sportartikel, Spiele, Spielzeug, Kunsthandwerk, Kunstmaterial und Zubehör' },
{ code: '38', label: 'Laborgeräte, optische und Präzisionsgeräte (ausgenommen Gläser)' },
{ code: '39', label: 'Möbel (einschließlich Büromöbel), Einrichtungsgegenstände, Haushaltsgeräte (ausgenommen Beleuchtung) und Reinigungsmittel' },
{ code: '41', label: 'Gesammeltes und gereinigtes Wasser' },
{ code: '42', label: 'Industriemaschinen' },
{ code: '43', label: 'Maschinen für Bergbau, Steinbruch, Bauwesen' },
{ code: '44', label: 'Baustoffe und zugehörige Artikel' },
{ code: '45', label: 'Bauarbeiten' },
{ code: '48', label: 'Softwarepaket und Informationssysteme' },
{ code: '50', label: 'Reparatur- und Wartungsdienste' },
{ code: '51', label: 'Installationsdienste (ausgenommen Software)' },
{ code: '55', label: 'Beherbergungs-, Gaststätten- und Handelsdienstleistungen' },
{ code: '60', label: 'Transportdienstleistungen (ohne Abfalltransport)' },
{ code: '63', label: 'Neben- und Hilfstätigkeiten im Verkehr; Reisebüros' },
{ code: '64', label: 'Post- und Telekommunikationsdienste' },
{ code: '65', label: 'Versorgungsbetriebe' },
{ code: '66', label: 'Finanz- und Versicherungsdienstleistungen' },
{ code: '70', label: 'Dienstleistungen im Zusammenhang mit Immobilien' },
{ code: '71', label: 'Architektur-, Bau-, Ingenieur- und Prüfdienste' },
{ code: '72', label: 'IT-Dienste: Beratung, Software-Entwicklung, Internet und Hilfestellung' },
{ code: '73', label: 'Forschungs- und Entwicklungsdienste sowie zugehörige Beratung' },
{ code: '75', label: 'Öffentliche Verwaltung, Verteidigung und Sozialversicherung' },
{ code: '76', label: 'Dienstleistungen im Zusammenhang mit der Öl- und Gasindustrie' },
{ code: '77', label: 'Landwirtschaftliche, forstwirtschaftliche, gartenbauliche Dienste, Aquakultur und Imkerei' },
{ code: '79', label: 'Unternehmensbezogene Dienstleistungen: Recht, Marketing, Consulting, Personal, Druck und Sicherheit' },
{ code: '80', label: 'Dienstleistungen des Bildungswesens und der Ausbildung' },
{ code: '85', label: 'Dienste im Gesundheits- und Sozialwesen' },
{ code: '90', label: 'Abwasser- und Abfallbeseitigung, Reinigung und Umweltschutzdienste' },
{ code: '92', label: 'Dienstleistungen der Freizeitgestaltung, der Kultur und des Sports' },
{ code: '98', label: 'Sonstige Gemeinschafts-, Sozial- und Individualdienstleistungen' },
];
/** O(1) code -> label lookup, built once from CPV_DIVISIONS. */
const DIVISION_LABEL_BY_CODE: Map<string, string> = new Map(
CPV_DIVISIONS.map((d) => [d.code, d.label]),
);
export function labelForDivision(code: string): string | null {
return DIVISION_LABEL_BY_CODE.get(code) ?? null;
}
/**
* normalizeCpv — strips everything but leading digits from a raw CPV code
* as stored (mixed formats: "45", "45000000", "45000000-7", possibly with
* trailing whitespace). Two-sided normalization (Pitfall 2): both the
* stored raw code and the user-selected division are normalized before
* comparison — never an exact match against one canonical shape.
*
* '45000000-7' -> '45000000' (check-digit suffix and any other non-digit
* characters, e.g. trailing whitespace, are stripped — only the leading
* run of digits is kept).
*/
export function normalizeCpv(raw: string): string {
if (!raw) return '';
const match = raw.match(/^\d+/);
return match ? match[0] : '';
}
/**
* divisionOf — the leading 2 digits of a normalized CPV code, i.e. the
* CPV division. Works whether the input is already normalized (8-digit or
* bare 2-digit) or raw (with a check-digit suffix) — normalizes internally
* first.
*
* divisionOf('45000000') -> '45'; divisionOf('45') -> '45';
* divisionOf('45000000-7') -> '45'.
*/
export function divisionOf(raw: string): string {
return normalizeCpv(raw).slice(0, 2);
}
/**
* cpvMatchesDivisions — true if ANY code in the array (in whatever mixed
* format it's stored) normalizes into ANY of the selected divisions.
* Used by the query layer as the in-process match for building the
* `cpvDivisions` backfill/normalizer value (Task 2) — the actual runtime
* filter uses the precomputed `cpvDivisions` column via Prisma `hasSome`
* (Task 3), not this function directly against raw cpvCodes.
*/
export function cpvMatchesDivisions(
codes: string[],
selectedDivisions: string[],
): boolean {
if (!codes.length || !selectedDivisions.length) return false;
const selected = new Set(selectedDivisions);
return codes.some((code) => selected.has(divisionOf(code)));
}