import { Injectable } from '@nestjs/common'; import { createHash } from 'crypto'; import { bundeslandFromRegion } from './geo/nuts-bundesland'; import type { NormalizedTenderFields, RawTenderRecord } from './tender.types'; /** * TenderNormalizerService — maps a RawTenderRecord (paired eForms-DE XML + * OCDS JSON) into the unified Tender field shape (SCHEMA-01), computing the * dedupKey and contentHash (SCHEMA-02 change-detection hook). * * Pure, no I/O (matches the DkvParserService transform-service shape: one * public entry point + private helpers). * * Field authority (RESEARCH.md Pattern 3 — reverses ARCHITECTURE.md's * blanket "prefer OCDS" guidance based on a live cross-check): * - eForms-DE XML is PRIMARY for deadlineAt / estimatedValue / procedureType * (OCDS conversion measurably drops these for some notices). * - OCDS is PRIMARY for ocid / buyerName / title / cpvCodes / region / plz. * - deadlineAt / estimatedValue are nullable by mandate (RESEARCH Pattern 4) * — null is the common case for Unterschwelle notices, not an edge case. */ @Injectable() export class TenderNormalizerService { normalize(raw: RawTenderRecord): NormalizedTenderFields { const eformsRoot = getEformsRoot(raw.eformsPayload); const ocdsTender = getOcdsTender(raw.ocdsPayload); const ocdsBuyer = getOcdsBuyer(raw.ocdsPayload); const title = textValue(ocdsTender?.title) ?? extractEformsTitle(eformsRoot) ?? 'Unbenannte Ausschreibung'; const buyerName = textValue(ocdsBuyer?.name); const cpvCodes = extractCpvCodes(ocdsTender); const region = textValue(ocdsBuyer?.address?.region); const plz = textValue(ocdsBuyer?.address?.postalCode); // Phase 11 (FILTER-02, Pitfall 1): derive bundesland from region's // NUTS-1 prefix so new ingests are filterable immediately — the // ~1671 pre-existing rows are backfilled by a one-time migration // (20260721140000_tender_bundesland_backfill). const bundesland = bundeslandFromRegion(region); const deadlineAt = extractEformsDeadline(eformsRoot) ?? parseOcdsDate(ocdsTender?.tenderPeriod?.endDate); const estimatedValue = extractEformsEstimatedValue(eformsRoot) ?? parseOcdsAmount(ocdsTender?.value); const procedureType = extractEformsProcedureType(eformsRoot) ?? textValue(ocdsTender?.procurementMethodDetails); const status = 'active'; const dedupKey = raw.ocid ? raw.ocid : `${raw.sourcePortal}:${raw.sourceNoticeId}`; const contentHash = computeContentHash({ title, deadlineAt, estimatedValue, status, }); return { sourcePortal: raw.sourcePortal, sourceNoticeId: raw.sourceNoticeId, ocid: raw.ocid ?? null, dedupKey, title, buyerName, cpvCodes, region, plz, bundesland, deadlineAt, estimatedValue, procedureType, status, sourceUrl: raw.sourceUrl ?? null, contentHash, publishedAt: raw.publishedAt ?? new Date(), }; } } // ─── Private: pure field-extraction helpers (module-level) ──────────────── interface OcdsAddress { region?: unknown; postalCode?: unknown; } interface OcdsParty { name?: unknown; address?: OcdsAddress; } interface OcdsClassification { scheme?: unknown; id?: unknown; } interface OcdsItem { classification?: OcdsClassification; } interface OcdsTender { title?: unknown; tenderPeriod?: { endDate?: unknown }; value?: { amount?: unknown }; procurementMethodDetails?: unknown; items?: OcdsItem[]; } function getOcdsTender(ocdsPayload: unknown): OcdsTender | undefined { if (!ocdsPayload || typeof ocdsPayload !== 'object') return undefined; return (ocdsPayload as { tender?: OcdsTender }).tender; } function getOcdsBuyer(ocdsPayload: unknown): OcdsParty | undefined { if (!ocdsPayload || typeof ocdsPayload !== 'object') return undefined; return (ocdsPayload as { buyer?: OcdsParty }).buyer; } /** Extract the sole root element's value from a fast-xml-parser document, robust across notice subtypes (ContractNotice, PriorInformationNotice, ...). */ function getEformsRoot(eformsPayload: unknown): Record { if (!eformsPayload || typeof eformsPayload !== 'object') return {}; const keys = Object.keys(eformsPayload as Record); const rootKey = keys[0]; if (!rootKey) return {}; const root = (eformsPayload as Record)[rootKey]; return root && typeof root === 'object' ? (root as Record) : {}; } /** fast-xml-parser represents a tag with both attributes and text content as `{ '@_attr': ..., '#text': value }`; a plain tag is just the raw string/number. */ function textValue(node: unknown): string | null { if (node === null || node === undefined) return null; if (typeof node === 'string') return node || null; if (typeof node === 'number') return String(node); if (typeof node === 'object' && '#text' in (node as Record)) { const t = (node as Record)['#text']; if (t === null || t === undefined) return null; return String(t); } return null; } function firstLot( root: Record, ): Record | null { const lot = root.ProcurementProjectLot; if (!lot) return null; if (Array.isArray(lot)) { return (lot[0] as Record) ?? null; } return lot as Record; } function extractEformsTitle(root: Record): string | null { const project = root.ProcurementProject as | Record | undefined; return textValue(project?.Name); } /** * Primary structured deadline source (RESEARCH.md Pattern 3): per-lot * `TenderingProcess/TenderSubmissionDeadlinePeriod/EndDate` (+ optional * `EndTime`), combined into a full ISO timestamp when both are present. */ function extractEformsDeadline(root: Record): Date | null { const lot = firstLot(root); const process = lot?.TenderingProcess as Record | undefined; const period = process?.TenderSubmissionDeadlinePeriod as | Record | undefined; const endDateRaw = textValue(period?.EndDate); if (!endDateRaw) return null; const endTimeRaw = textValue(period?.EndTime); const datePart = endDateRaw.replace(/[+-]\d{2}:\d{2}$/, ''); const combined = endTimeRaw ? `${datePart}T${endTimeRaw}` : datePart; const parsed = new Date(combined); return Number.isNaN(parsed.getTime()) ? null : parsed; } /** Primary structured value source (RESEARCH.md Pattern 3): root `ProcurementProject/RequestedTenderTotal/EstimatedOverallContractAmount`. */ function extractEformsEstimatedValue( root: Record, ): number | null { const project = root.ProcurementProject as | Record | undefined; const total = project?.RequestedTenderTotal as | Record | undefined; const amountRaw = textValue(total?.EstimatedOverallContractAmount); if (!amountRaw) return null; const parsed = Number.parseFloat(amountRaw); return Number.isNaN(parsed) ? null : parsed; } /** Primary structured procedure-type source: root `TenderingProcess/ProcedureCode`. */ function extractEformsProcedureType( root: Record, ): string | null { const process = root.TenderingProcess as Record | undefined; return textValue(process?.ProcedureCode); } function extractCpvCodes(ocdsTender: OcdsTender | undefined): string[] { const items = Array.isArray(ocdsTender?.items) ? ocdsTender.items : []; const codes = items .map((item) => item?.classification?.id) .filter((id): id is string => typeof id === 'string'); return Array.from(new Set(codes)); } function parseOcdsDate(value: unknown): Date | null { if (typeof value !== 'string' || !value) return null; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function parseOcdsAmount(value: { amount?: unknown } | undefined): number | null { const amount = value?.amount; return typeof amount === 'number' ? amount : null; } function computeContentHash(fields: { title: string; deadlineAt: Date | null; estimatedValue: number | null; status: string; }): string { const raw = [ fields.title, fields.deadlineAt ? fields.deadlineAt.toISOString() : '', fields.estimatedValue !== null ? String(fields.estimatedValue) : '', fields.status, ].join('|'); return createHash('sha256').update(raw).digest('hex'); }