diff --git a/apps/api/src/tenders/cpv/cpv-catalog.ts b/apps/api/src/tenders/cpv/cpv-catalog.ts new file mode 100644 index 0000000..ffc0bfa --- /dev/null +++ b/apps/api/src/tenders/cpv/cpv-catalog.ts @@ -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 = 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))); +}