diff --git a/.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-PLAN.md b/.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-PLAN.md
new file mode 100644
index 0000000..3eb4e28
--- /dev/null
+++ b/.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-PLAN.md
@@ -0,0 +1,141 @@
+---
+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
+
+
+