--- phase: quick-260723-e7i plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/api/src/tenders/tender-normalizer.service.ts - apps/api/src/tenders/tender-normalizer.service.spec.ts autonomous: true requirements: - INGEST-02 - INGEST-03 - SCHEMA-03 gap_closure: true must_haves: truths: - "Ein ai-netserver-RawTenderRecord (flache ocdsPayload-Bag) normalisiert zu title/buyerName/procedureType/deadlineAt aus der Bag, nicht zu Defaults" - "Ein cosinex-dtvp-RawTenderRecord normalisiert identisch korrekt aus derselben Bag" - "Der bestehende doe-opendata-Pfad (eForms/OCDS) bleibt unveraendert (keine Regression)" - "Fingerprint eines normalisierten NetServer/Cosinex-Records nutzt echten Titel/Buyer/Frist und kann daher mit dem DÖE-Gegenstueck kollidieren" artifacts: - apps/api/src/tenders/tender-normalizer.service.ts - apps/api/src/tenders/tender-normalizer.service.spec.ts key_links: - "netserver.adapter.ts / cosinex.adapter.ts (ocdsPayload-Bag) -> TenderNormalizerService.normalize() (per-sourceType-Dispatch)" --- Schliesst den Normalizer-Gap aus 13-VERIFICATION.md: `TenderNormalizerService.normalize()` versteht die generische flache `ocdsPayload`-Bag der NetServer- und Cosinex-Adapter nicht (liest nur `.tender`/`.buyer` der DÖE-OCDS-Form). Dadurch normalisieren alle ai-netserver-/cosinex-dtvp-Datensaetze zu `title='Unbenannte Ausschreibung'`, `buyerName=null`, `deadlineAt=null` — und ihr Fingerprint kollidiert nie mit dem echten DÖE-Gegenstueck. Fix: per-`sourceType`-Dispatch in `normalize()`. Fuer `ai-netserver` und `cosinex-dtvp` wird die flache Bag `{title, buyerName, procedureType, legalFramework, deadlineAt(ISO-String|null)}` direkt in `NormalizedTenderFields` gemappt. Der bestehende DÖE-Pfad bleibt exakt gleich. Purpose: Blockt ROADMAP-Kriterien 1/2/3 (INGEST-02, INGEST-03, SCHEMA-03) aus Phase 13. Ohne diesen Fix sind NetServer/Cosinex-Treffer nicht "korrekt normalisiert" und Cross-Source-Dedup zwischen DÖE und Scraper ist praktisch unerreichbar. Output: Erweiterter `tender-normalizer.service.ts` + neue Unit-Tests fuer beide Bag-Quellen und die DÖE-Regression. Bounded: nur `tender-normalizer.service.ts` + zugehoerige Spec. Kein Schema-Change, keine Adapter-Aenderung (die Bag-Konvention bleibt). @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/phases/13-scraping-adapters-cross-source-dedup/13-VERIFICATION.md @apps/api/src/tenders/tender-normalizer.service.ts @apps/api/src/tenders/tender.types.ts @apps/api/src/tenders/tender-fingerprint.ts @apps/api/src/tenders/tender-normalizer.service.spec.ts @apps/api/src/tenders/adapters/netserver.adapter.ts @CLAUDE.md Task 1: Per-sourceType-Dispatch + geteilte assemble-Hilfsmethode im Normalizer apps/api/src/tenders/tender-normalizer.service.ts - ai-netserver-Bag {title:'Bauleistung X', buyerName:'Stadt Y', procedureType:'Offenes Verfahren', legalFramework:'VOB/A', deadlineAt:'2026-09-01T10:00:00.000Z'} -> NormalizedTenderFields mit title='Bauleistung X', buyerName='Stadt Y', procedureType='Offenes Verfahren', deadlineAt=Date(2026-09-01T10:00:00Z), cpvCodes=[], cpvDivisions=[], region=null, plz=null, bundesland=null, estimatedValue=null - cosinex-dtvp-Bag identischer Shape -> identisches Mapping-Verhalten - Bag mit buyerName:null, procedureType:null, deadlineAt:null -> buyerName=null, procedureType=null, deadlineAt=null, title-Fallback greift wenn title fehlt/leer - Bag mit fehlendem/leerem title -> title='Unbenannte Ausschreibung' - doe-opendata-RawTenderRecord (eForms/OCDS) -> unveraendertes Ergebnis wie bisher (title/buyerName/cpvCodes/deadlineAt aus OCDS/eForms) - status='active', dedupKey (ocid sonst `${sourcePortal}:${sourceNoticeId}`), contentHash und publishedAt werden fuer ALLE Quellen identisch berechnet Erweitere `normalize()` um einen `switch (raw.sourceType)`-Dispatch, der die quellenspezifische Feldextraktion von der geteilten Assembly trennt. Schritt 1 — Extrahiere eine private Instanz-Methode `assemble(raw, coreFields)`, die den Tail von `normalize()` (status='active', dedupKey, contentHash via bestehendem computeContentHash, publishedAt-Fallback new Date()) plus die quellen-invarianten Felder (sourcePortal, sourceNoticeId, ocid, sourceUrl) uebernimmt und ein vollstaendiges NormalizedTenderFields zurueckgibt. `coreFields` traegt die 10 quellenspezifischen Felder: title, buyerName, cpvCodes, cpvDivisions, region, plz, bundesland, deadlineAt, estimatedValue, procedureType. Definiere dafuer einen lokalen Typ (z.B. `type CoreTenderFields = Omit`). computeContentHash NICHT duplizieren — aus assemble aufrufen. Schritt 2 — Der bestehende DÖE-Extraktionsblock (getEformsRoot/getOcdsTender/getOcdsBuyer und die daraus abgeleiteten title/buyerName/cpvCodes/cpvDivisions/region/plz/bundesland/deadlineAt/estimatedValue/procedureType) wird in eine private Methode `normalizeDoe(raw)` verschoben, die genau diese CoreTenderFields liefert und dann `this.assemble(raw, core)` zurueckgibt. Ergebnis muss byte-identisch zum jetzigen Verhalten sein (Regression-Schutz). Schritt 3 — Neue private Methode `normalizeBag(raw)` fuer die flache Bag-Quellen. Lies `raw.ocdsPayload` als `{title?, buyerName?, procedureType?, legalFramework?, deadlineAt?}` defensiv (typeof object check, sonst leeres Objekt). Mappe: title = nicht-leerer String sonst 'Unbenannte Ausschreibung'; buyerName = String-oder-null; procedureType = String-oder-null; deadlineAt = parseOcdsDate(bag.deadlineAt) (existiert bereits, akzeptiert ISO-String, null-tolerant); cpvCodes=[], cpvDivisions=[], region=null, plz=null, bundesland=null, estimatedValue=null. legalFramework wird bewusst NICHT gemappt (kein Zielfeld in NormalizedTenderFields). Dann `this.assemble(raw, core)`. Schritt 4 — In `normalize()`: `switch (raw.sourceType) { case 'ai-netserver': case 'cosinex-dtvp': return this.normalizeBag(raw); case 'doe-opendata': default: return this.normalizeDoe(raw); }`. Der default MUSS auf den DÖE-Pfad zeigen (kein throw), damit die additive Union kuenftig nicht bricht. Beachte D-01-Konvention: buyerName/procedureType koennen null sein — keine Fallback-Strings dafuer erfinden. Nur title hat den Fallback 'Unbenannte Ausschreibung'. cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run src/tenders/tender-normalizer.service.spec.ts tsc clean; normalize() dispatcht per sourceType; DÖE-Pfad unveraendert (bestehende Normalizer-Tests weiter gruen); computeContentHash nicht dupliziert (aus assemble aufgerufen). Task 2: Unit-Tests fuer beide Bag-Quellen + DÖE-Regression + Null-Felder apps/api/src/tenders/tender-normalizer.service.spec.ts Ergaenze den bestehenden `describe('TenderNormalizerService', ...)` um einen neuen `describe`-Block fuer die generische Bag-Normalisierung. Nutze keine Fixture-ZIPs — konstruiere RawTenderRecord inline (die Bag-Quellen haben keine eForms/OCDS-Fixtures). Hilfsfunktion `bagRecord(sourceType, overrides)` baut ein RawTenderRecord mit sourceType, sourcePortal (z.B. 'tender24' bzw. 'cosinex-dtvp'), sourceNoticeId, ocid=undefined, sourceUrl, fetchedAt=new Date(), publishedAt=null, eformsPayload=null, ocdsPayload={title, buyerName, procedureType, legalFramework, deadlineAt}. Testfaelle: - ai-netserver: voll besetzte Bag (deadlineAt als ISO-String) -> title/buyerName/procedureType aus Bag; deadlineAt ist ein Date mit korrektem ISO-Wert; cpvCodes=[], cpvDivisions=[], region=null, plz=null, bundesland=null, estimatedValue=null; dedupKey === `${sourcePortal}:${sourceNoticeId}` (kein ocid); status==='active'. - cosinex-dtvp: analoge voll besetzte Bag -> identisches Mapping-Verhalten. - Null-/Leer-Felder: Bag mit buyerName=null, procedureType=null, deadlineAt=null, title='' -> buyerName===null, procedureType===null, deadlineAt===null, title==='Unbenannte Ausschreibung'. - contentHash: ist ein 64-stelliger Hex-String (sha256) fuer eine Bag-Quelle (beweist Assembly-Wiederverwendung). - Regression DÖE: mindestens ein bestehender Assert-Pfad ueber loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE) bleibt (title/deadlineAt aus eForms) — bestaetigt, dass der Refactor den DÖE-Pfad nicht veraendert. Falls schon vorhanden, nicht duplizieren. Importiere den SourceType/RawTenderRecord-Typ aus './tender.types'. Halte die Tests deterministisch (feste ISO-Strings, kein Date.now-Vergleich fuer deadlineAt). cd apps/api && npx vitest run src/tenders/tender-normalizer.service.spec.ts Neue Tests decken ai-netserver-Bag, cosinex-dtvp-Bag, Null-/Leer-Felder und contentHash-Format ab; DÖE-Regressionstest bleibt gruen; gesamte Normalizer-Spec passt. Task 3: Volle API-Test-Suite + Typecheck als Nicht-Regressions-Gate apps/api/src/tenders/tender-normalizer.service.ts Fuehre die vollstaendige API-Test-Suite und den Typecheck aus, um zu bestaetigen, dass der Normalizer-Refactor keine der 284 bestehenden Tests (insb. tender-dedup.service.spec.ts, tender-ingestion.service.spec.ts, tender-fingerprint) bricht. Keine Code-Aenderung in diesem Task ausser evtl. kleiner Fixes, falls ein nachgelagerter Test rot wird. Diese Datei ist im files_modified nur als Anker gelistet. cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run tsc exit 0; gesamte API-Suite gruen (>=284 Tests, jetzt inkl. der neuen Bag-Tests). - `cd apps/api && npx vitest run src/tenders/tender-normalizer.service.spec.ts` — alle Normalizer-Tests inkl. Bag-Faelle gruen - `cd apps/api && npx tsc --noEmit -p tsconfig.json` — clean - `cd apps/api && npx vitest run` — volle Suite gruen (keine Dedup-/Ingestion-Regression) - Manuell/gedanklich: fuer eine identische ai-netserver- und DÖE-Bag mit gleichem Titel/Buyer/Frist erzeugt tenderFingerprint() denselben Hash (Voraussetzung fuer Cross-Source-Dedup — bereits durch korrektes Mapping erfuellt). - normalize() dispatcht per sourceType; ai-netserver und cosinex-dtvp mappen die flache Bag korrekt in NormalizedTenderFields - title-Fallback 'Unbenannte Ausschreibung'; buyerName/procedureType/deadlineAt null-tolerant; cpvCodes/cpvDivisions=[], region/plz/bundesland/estimatedValue=null - status/dedupKey/contentHash/publishedAt via geteilter assemble-Methode (computeContentHash nicht dupliziert) - DÖE-Pfad unveraendert (Regression-Tests gruen) - Neue Unit-Tests decken beide Bag-Quellen + Null-Felder + DÖE-Regression ab - Volle API-Suite + Typecheck gruen Create `.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-SUMMARY.md` when done