Files
tessera-ctl/.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-PLAN.md
T

142 lines
11 KiB
Markdown

---
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)"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Per-sourceType-Dispatch + geteilte assemble-Hilfsmethode im Normalizer</name>
<files>apps/api/src/tenders/tender-normalizer.service.ts</files>
<behavior>
- 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
</behavior>
<action>
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<NormalizedTenderFields, 'sourcePortal'|'sourceNoticeId'|'ocid'|'dedupKey'|'status'|'sourceUrl'|'contentHash'|'publishedAt'|'fingerprint'>`). 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'.
</action>
<verify>
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run src/tenders/tender-normalizer.service.spec.ts</automated>
</verify>
<done>tsc clean; normalize() dispatcht per sourceType; DÖE-Pfad unveraendert (bestehende Normalizer-Tests weiter gruen); computeContentHash nicht dupliziert (aus assemble aufgerufen).</done>
</task>
<task type="auto">
<name>Task 2: Unit-Tests fuer beide Bag-Quellen + DÖE-Regression + Null-Felder</name>
<files>apps/api/src/tenders/tender-normalizer.service.spec.ts</files>
<action>
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).
</action>
<verify>
<automated>cd apps/api && npx vitest run src/tenders/tender-normalizer.service.spec.ts</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Volle API-Test-Suite + Typecheck als Nicht-Regressions-Gate</name>
<files>apps/api/src/tenders/tender-normalizer.service.ts</files>
<action>
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.
</action>
<verify>
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run</automated>
</verify>
<done>tsc exit 0; gesamte API-Suite gruen (>=284 Tests, jetzt inkl. der neuen Bag-Tests).</done>
</task>
</tasks>
<verification>
- `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).
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
Create `.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-SUMMARY.md` when done
</output>