diff --git a/apps/api/src/tenders/geo/nuts-bundesland.ts b/apps/api/src/tenders/geo/nuts-bundesland.ts new file mode 100644 index 0000000..b077795 --- /dev/null +++ b/apps/api/src/tenders/geo/nuts-bundesland.ts @@ -0,0 +1,67 @@ +/** + * NUTS-1 -> Bundesland derivation (FILTER-02, D-02, Pitfall 1). + * + * `Tender.bundesland` is 100% NULL in the live DB (0/1671 rows, verified) + * because the Phase-10 normalizer explicitly deferred NUTS->Bundesland + * mapping to this phase (`tender-normalizer.service.ts` previously set + * `bundesland = null` unconditionally). Without this derivation the + * Bundesland filter would return a constant zero result and look like a + * bug rather than an empty column. + * + * `region` holds a EU NUTS code (e.g. `DE212`); the first 3 characters + * (`DE` + one Bundesland-level digit/letter) form the stable NUTS-1 + * region — the standardized, deterministic EU classification scheme, + * unlike a PLZ-range heuristic (PLZ zones cross Bundesland borders). + * + * Validated against real region samples pulled from the live DB + * (`docker exec tessera-ctl-db-1 psql`, Assumption A1): + * DE212 (60 rows) -> Bayern (Oberbayern) + * DE300 (56 rows) -> Berlin + * DE600 (38 rows) -> Hamburg + * DE712 (34 rows) -> Hessen (Darmstadt) + * DEA22 (24 rows) -> Nordrhein-Westfalen (Köln) + */ + +export const NUTS1_BUNDESLAND: Record = { + DE1: 'Baden-Württemberg', + DE2: 'Bayern', + DE3: 'Berlin', + DE4: 'Brandenburg', + DE5: 'Bremen', + DE6: 'Hamburg', + DE7: 'Hessen', + DE8: 'Mecklenburg-Vorpommern', + DE9: 'Niedersachsen', + DEA: 'Nordrhein-Westfalen', + DEB: 'Rheinland-Pfalz', + DEC: 'Saarland', + DED: 'Sachsen', + DEE: 'Sachsen-Anhalt', + DEF: 'Schleswig-Holstein', + DEG: 'Thüringen', +}; + +/** + * Derive the Bundesland name from a NUTS region code's NUTS-1 (3-char) + * prefix. Null-safe: null/undefined/empty region (17%+ of live rows) and + * unrecognized prefixes return `null` instead of throwing — never crash + * on the common missing-region case. + */ +export function bundeslandFromRegion(region?: string | null): string | null { + if (!region) return null; + const prefix = region.slice(0, 3).toUpperCase(); + return NUTS1_BUNDESLAND[prefix] ?? null; +} + +/** + * Reverse lookup: Bundesland display name -> NUTS-1 prefix, used by the + * query builder for `region: { startsWith: nutsPrefixFor(bundesland) }` + * as a fallback path (and available even independent of the backfilled + * `bundesland` column). + */ +export function nutsPrefixFor(bundesland: string): string | null { + for (const [prefix, name] of Object.entries(NUTS1_BUNDESLAND)) { + if (name === bundesland) return prefix; + } + return null; +}