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:
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user