From f3cd70219719dad5a2bb88d4787fb70b59f3e206 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 08:31:27 +0200 Subject: [PATCH] =?UTF-8?q?docs(13):=20revise=20plans=20per=20checker=20FL?= =?UTF-8?q?AG=20=E2=80=94=20SCHEMA-02=20preserve=20+=20task=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 13-03: dedup resolve() created=false branch preserves Phase-10 SCHEMA-02 change detection (mutable fields + contentHash on changed re-poll); spec covers it. Prevents silent regression of DÖE re-poll updates. - 13-01: reorder so fingerprint fn (Task 1) precedes fingerprint backfill (Task 2) — removes forward reference. - 13-VALIDATION: task-id + coverage rows updated to match. Co-Authored-By: Claude Opus 4.8 --- .../13-01-PLAN.md | 42 +++++++++---------- .../13-03-PLAN.md | 12 ++++-- .../13-VALIDATION.md | 8 ++-- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-01-PLAN.md b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-01-PLAN.md index 59c3128..93f7a10 100644 --- a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-01-PLAN.md +++ b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-01-PLAN.md @@ -50,26 +50,8 @@ Output: schema.prisma (TenderSource + fingerprint), lokal angewendete Migration - - Task 1: TenderSource-Modell + fingerprint-Spalte + Migration + Backfill - apps/api/prisma/schema.prisma, apps/api/prisma/migrations/, apps/api/src/tenders/backfill-tender-source.ts - -Ergaenze in `schema.prisma` (per SCHEMA-03 / D-03, Referenz-Snippet in 13-RESEARCH.md "TenderSource-Modell (Prisma)"): neues `model TenderSource` mit id (uuid), tenderId, sourcePortal, sourceNoticeId, ocid (nullable), sourceUrl (nullable), createdAt, Relation `tender Tender @relation(fields:[tenderId], references:[id], onDelete: Cascade)`, `@@unique([sourcePortal, sourceNoticeId])` und `@@index([tenderId])`. Ergaenze in `model Tender`: `fingerprint String?` (nullable, additiv), `sources TenderSource[]`, `@@index([fingerprint])`. `dedupKey @unique` bleibt UNVERAENDERT (SCHEMA-02-Upsert-Target, D-01 — kein Drop in dieser Phase). - -Erzeuge eine handgeschriebene Migration (Verzeichnis `apps/api/prisma/migrations/20260723120000_add_tender_source/migration.sql`) in ZWEI logischen Schritten in korrekter Reihenfolge (Pitfall 5): (a) CREATE TABLE "TenderSource" + ADD COLUMN "fingerprint" auf "Tender" + Indizes + Unique-Constraint; (b) Daten-Backfill `INSERT INTO "TenderSource" (...) SELECT gen_random_uuid(), t.id, t."sourcePortal", t."sourceNoticeId", t.ocid, t."sourceUrl", now() FROM "Tender" t;` (SQL-Skizze in 13-RESEARCH.md "Backfill-Migration"). Der Unique-Constraint wird NACH dem Backfill-Insert wirksam bzw. das Insert erzeugt keine Duplikate (DÖE-noticeIds sind live eindeutig). - -Der `fingerprint`-Backfill der Bestands-Tender lebt NICHT in reinem SQL (Umlaut-/CPV-Normalisierung lebt im Code): schreibe ein einmaliges TS-Backfill-Script `backfill-tender-source.ts`, das alle Tender laedt, `tenderFingerprint(...)` (Task 2) aus title/buyerName/cpvDivisions/deadlineAt/estimatedValue berechnet und `Tender.fingerprint` per updateMany/Schleife setzt. estimatedValue ist `Decimal?` — in number/null konvertieren, bevor es an valueBucket geht. - -Wende Migration lokal an (KEIN Docker-Deploy Testserver, MEMORY): `docker compose exec -T db psql -U tessera -d tessera_dev -f -` bzw. `pnpm --filter @tessera/api exec prisma migrate deploy` gegen die lokale DB; danach `pnpm --filter @tessera/api exec prisma generate`. DB-Name lokal verifizieren (tessera_dev laut Phase-12-Vorbild; Research nennt `tessera`) — vor dem Anwenden `\l` pruefen. - - - cd apps/api && npx prisma validate && npx prisma generate - - `model TenderSource` + `Tender.fingerprint` + `Tender.sources` existieren; `prisma validate` gruen; Migration lokal angewendet; `SELECT count(*) FROM "TenderSource"` == count der Tender (manuelle DB-Pruefung, siehe 13-VALIDATION.md). - - - Task 2: Pure NULL-tolerante Fingerprint-Funktion (SCHEMA-03, D-04) + Task 1: Pure NULL-tolerante Fingerprint-Funktion (SCHEMA-03, D-04) apps/api/src/tenders/tender-fingerprint.ts, apps/api/src/tenders/tender-fingerprint.spec.ts - Gleicher Titel + Auftraggeber + CPV-Division, beide Wert=NULL und Frist=NULL -> gleicher Fingerprint (NULL-Toleranz, 92.4% NULL Wert / 15.7% NULL Frist live). @@ -80,12 +62,30 @@ Wende Migration lokal an (KEIN Docker-Deploy Testserver, MEMORY): `docker compos - Rueckgabe ist stabiler sha256-Hex-String (deterministisch ueber Laeufe). -Implementiere `tender-fingerprint.ts` als pure Funktion `tenderFingerprint(f: { buyerName: string|null; title: string; cpvDivisions: string[]; deadlineAt: Date|null; estimatedValue: number|null }): string` exakt nach 13-RESEARCH.md Pattern 4: interne Helfer `normText` (lowercase, ae/oe/ue/ss, non-alnum->space, trim), `cpvDivisionKey` (Set+sort+join), `valueBucket` (NULL->"", sonst `String(Math.floor(Math.log10(Math.max(v,1))))`), `deadlineKey` (Datum auf Tageskorn oder ""). Kanonischer String `[buyer, title, cpvDivKey, deadlineKey, valueBucket].join('|')` -> `createHash('sha256').update(...).digest('hex')` (Node crypto, kein Selbstbau, V6). KEIN Aehnlichkeits-Threshold (deterministisch, O(1)-Lookup). Schreibe die Tests RED-first gemaess behavior-Block, dann Implementierung bis gruen. +Diese Funktion MUSS vor dem Fingerprint-Backfill (Task 2) existieren — daher zuerst. Implementiere `tender-fingerprint.ts` als pure Funktion `tenderFingerprint(f: { buyerName: string|null; title: string; cpvDivisions: string[]; deadlineAt: Date|null; estimatedValue: number|null }): string` exakt nach 13-RESEARCH.md Pattern 4: interne Helfer `normText` (lowercase, ae/oe/ue/ss, non-alnum->space, trim), `cpvDivisionKey` (Set+sort+join), `valueBucket` (NULL->"", sonst `String(Math.floor(Math.log10(Math.max(v,1))))`), `deadlineKey` (Datum auf Tageskorn oder ""). Kanonischer String `[buyer, title, cpvDivKey, deadlineKey, valueBucket].join('|')` -> `createHash('sha256').update(...).digest('hex')` (Node crypto, kein Selbstbau, V6). KEIN Aehnlichkeits-Threshold (deterministisch, O(1)-Lookup). Schreibe die Tests RED-first gemaess behavior-Block, dann Implementierung bis gruen. pnpm --filter @tessera/api test -- tender-fingerprint - Alle behavior-Faelle gruen; Funktion ist pure (kein Prisma/IO-Import); NULL-Toleranz + Kollisions-Fall abgedeckt. + Alle behavior-Faelle gruen; Funktion ist pure (kein Prisma/IO-Import); NULL-Toleranz + Kollisions-Fall abgedeckt; steht fuer Task 2 zur Verfuegung. + + + + Task 2: TenderSource-Modell + fingerprint-Spalte + Migration + Backfill + apps/api/prisma/schema.prisma, apps/api/prisma/migrations/, apps/api/src/tenders/backfill-tender-source.ts + +Ergaenze in `schema.prisma` (per SCHEMA-03 / D-03, Referenz-Snippet in 13-RESEARCH.md "TenderSource-Modell (Prisma)"): neues `model TenderSource` mit id (uuid), tenderId, sourcePortal, sourceNoticeId, ocid (nullable), sourceUrl (nullable), createdAt, Relation `tender Tender @relation(fields:[tenderId], references:[id], onDelete: Cascade)`, `@@unique([sourcePortal, sourceNoticeId])` und `@@index([tenderId])`. Ergaenze in `model Tender`: `fingerprint String?` (nullable, additiv), `sources TenderSource[]`, `@@index([fingerprint])`. `dedupKey @unique` bleibt UNVERAENDERT (SCHEMA-02-Upsert-Target, D-01 — kein Drop in dieser Phase). + +Erzeuge eine handgeschriebene Migration (Verzeichnis `apps/api/prisma/migrations/20260723120000_add_tender_source/migration.sql`) in ZWEI logischen Schritten in korrekter Reihenfolge (Pitfall 5): (a) CREATE TABLE "TenderSource" + ADD COLUMN "fingerprint" auf "Tender" + Indizes + Unique-Constraint; (b) Daten-Backfill `INSERT INTO "TenderSource" (...) SELECT gen_random_uuid(), t.id, t."sourcePortal", t."sourceNoticeId", t.ocid, t."sourceUrl", now() FROM "Tender" t;` (SQL-Skizze in 13-RESEARCH.md "Backfill-Migration"). Der Unique-Constraint wird NACH dem Backfill-Insert wirksam bzw. das Insert erzeugt keine Duplikate (DÖE-noticeIds sind live eindeutig). + +Der `fingerprint`-Backfill der Bestands-Tender lebt NICHT in reinem SQL (Umlaut-/CPV-Normalisierung lebt im Code): schreibe ein einmaliges TS-Backfill-Script `backfill-tender-source.ts`, das alle Tender laedt, die in Task 1 gebaute `tenderFingerprint(...)` aus title/buyerName/cpvDivisions/deadlineAt/estimatedValue berechnet und `Tender.fingerprint` per updateMany/Schleife setzt. estimatedValue ist `Decimal?` — in number/null konvertieren, bevor es an valueBucket geht. + +Wende Migration lokal an (KEIN Docker-Deploy Testserver, MEMORY): `docker compose exec -T db psql -U tessera -d tessera_dev -f -` bzw. `pnpm --filter @tessera/api exec prisma migrate deploy` gegen die lokale DB; danach `pnpm --filter @tessera/api exec prisma generate`. DB-Name lokal verifizieren (tessera_dev laut Phase-12-Vorbild; Research nennt `tessera`) — vor dem Anwenden `\l` pruefen. + + + cd apps/api && npx prisma validate && npx prisma generate + + `model TenderSource` + `Tender.fingerprint` + `Tender.sources` existieren; `prisma validate` gruen; Migration lokal angewendet; fingerprint-Backfill nutzt die Task-1-Funktion; `SELECT count(*) FROM "TenderSource"` == count der Tender (manuelle DB-Pruefung, siehe 13-VALIDATION.md). diff --git a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-03-PLAN.md b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-03-PLAN.md index 3019630..32e1025 100644 --- a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-03-PLAN.md +++ b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-03-PLAN.md @@ -17,6 +17,7 @@ must_haves: - "Mit nur DÖE aktiv (activePortalCount < 2) laeuft die Fingerprint-Stufe NIE — zwei Tender mit gleichem Fingerprint bleiben zwei Tender (D-05, Erfolgskriterium 3, inert)." - "Ab der 2. aktiven Quelle haengt ein Fingerprint-Match eine zusaetzliche TenderSource an den bestehenden Tender an, statt einen neuen Tender zu erzeugen (D-03/D-04)." - "pollDueSources faechert ueber ALLE aktiven Configs (findMany isActive), mit catch-per-source (eine kaputte Quelle killt den Tick nicht, D-01)." + - "SCHEMA-02-Change-Detection bleibt erhalten: ein Re-Poll mit geaendertem contentHash aktualisiert die mutable Tender-Felder weiterhin (kein Phase-10-Regress) — die Logik wandert vom direkten upsert in resolve()." artifacts: - apps/api/src/tenders/tender-dedup.service.ts key_links: @@ -59,18 +60,21 @@ Output: tender-dedup.service.ts (+ spec), erweitertes pollDueSources (+ spec), M - dedupActive=true, OCID-Match: haengt TenderSource an bestehenden Tender, created=false. - dedupActive=true, kein OCID aber source:noticeId bereits vorhanden (idempotenter Re-Poll): upsert der TenderSource, kein neuer Tender. - dedupActive=true, kein OCID/noticeId-Match aber Fingerprint-Match: haengt zusaetzliche TenderSource an, created=false (D-03 Merge). + - SCHEMA-02-Erhalt (created=false, Re-Poll derselben Quelle): wenn sich der contentHash gegenueber dem Bestands-Tender geaendert hat, werden dessen mutable Felder (title, buyerName, cpvCodes, cpvDivisions, region, plz, bundesland, deadlineAt, estimatedValue, procedureType, sourceUrl, contentHash) aktualisiert — exakt die Phase-10-SCHEMA-02-Change-Detection. Ein DÖE-Re-Poll mit geaenderter Frist/Titel MUSS den Tender-Kern weiter aktualisieren (kein Regress). - Kein Match: tender.create (inkl. fingerprint) + tenderSource.create, created=true. - tenderSource.upsert nutzt @@unique([sourcePortal, sourceNoticeId]) als where-Target. -Implementiere `tender-dedup.service.ts` (`@Injectable`) nach 13-RESEARCH.md Pattern 5: Methode `resolve(n: NormalizedTenderFields, opts: { dedupActive: boolean }): Promise<{ tenderId: string; created: boolean }>`. Reihenfolge: (1) OCID-Match `prisma.tender.findFirst({ where: { ocid } })` falls ocid vorhanden; (2) `prisma.tenderSource.findUnique({ where: { sourcePortal_sourceNoticeId: {...} } })` -> zugehoerigen Tender; (3) NUR wenn `opts.dedupActive` und noch kein Match: `prisma.tender.findFirst({ where: { fingerprint: tenderFingerprint(n) } })`. Bei Match: `tenderSource.upsert` (create: an match.id haengen, update: sourceUrl) und `created:false`. Ohne Match: `tender.create({ data: { ...felder, fingerprint: tenderFingerprint(n) } })` + `tenderSource.create`, `created:true`. Der `dedupActive`-Gate ist der D-05-Kern — Stufe 3 wird bei nur einer Quelle NIE erreicht. +Implementiere `tender-dedup.service.ts` (`@Injectable`) nach 13-RESEARCH.md Pattern 5: Methode `resolve(n: NormalizedTenderFields, opts: { dedupActive: boolean }): Promise<{ tenderId: string; created: boolean }>`. Reihenfolge: (1) OCID-Match `prisma.tender.findFirst({ where: { ocid } })` falls ocid vorhanden; (2) `prisma.tenderSource.findUnique({ where: { sourcePortal_sourceNoticeId: {...} } })` -> zugehoerigen Tender; (3) NUR wenn `opts.dedupActive` und noch kein Match: `prisma.tender.findFirst({ where: { fingerprint: tenderFingerprint(n) } })`. -WICHTIG (T-10-09 / Phase-10-Muster): plain PrismaService, KEIN forTenant()/RLS — Tender/TenderSource sind plattform-global. Schreibe die Spec RED-first mit gemocktem PrismaService (kein echtes IO) nach behavior-Block; der inert-Fall (dedupActive=false -> zwei Tender) ist der D-05-Beweis. +Bei Match (`created:false`): `tenderSource.upsert` (create: an match.id haengen, update: sourceUrl). ZUSAETZLICH — SCHEMA-02-Change-Detection ERHALTEN (Regress-Vermeidung): der bestehende Phase-10-Update-Pfad von `pollDueSources` (das UPDATE des `tender.upsert`) darf NICHT verloren gehen. Wenn `n.contentHash` vom contentHash des Matches abweicht, `prisma.tender.update({ where: { id: match.id }, data: { title, buyerName, cpvCodes, cpvDivisions, region, plz, bundesland, deadlineAt, estimatedValue, procedureType, sourceUrl, contentHash } })` (exakt die mutable-Feldliste aus dem heutigen `tender-ingestion.service.ts`-UPDATE-Block). So aktualisiert ein DÖE-Re-Poll mit geaenderter Frist/Titel/contentHash den Tender-Kern weiterhin — nur die create-vs-update-Entscheidung wandert vom direkten upsert in den Resolver, die SCHEMA-02-Semantik bleibt identisch. Ohne Match (`created:true`): `tender.create({ data: { ...felder, fingerprint: tenderFingerprint(n) } })` + `tenderSource.create`. Der `dedupActive`-Gate ist der D-05-Kern — Stufe 3 wird bei nur einer Quelle NIE erreicht. + +WICHTIG (T-10-09 / Phase-10-Muster): plain PrismaService, KEIN forTenant()/RLS — Tender/TenderSource sind plattform-global. Schreibe die Spec RED-first mit gemocktem PrismaService (kein echtes IO) nach behavior-Block; der inert-Fall (dedupActive=false -> zwei Tender) ist der D-05-Beweis; ein expliziter Test deckt den SCHEMA-02-Erhalt (Match mit geaendertem contentHash -> tender.update der mutable Felder). pnpm --filter @tessera/api test -- tender-dedup - Alle behavior-Faelle gruen; inert bei einer Quelle, Merge ab zwei Quellen; nutzt tenderFingerprint aus 13-01. + Alle behavior-Faelle gruen; inert bei einer Quelle, Merge ab zwei Quellen; nutzt tenderFingerprint aus 13-01; SCHEMA-02-Change-Detection bleibt erhalten (Match+geaenderter contentHash -> tender.update, getestet). @@ -79,7 +83,7 @@ WICHTIG (T-10-09 / Phase-10-Muster): plain PrismaService, KEIN forTenant()/RLS Baue `pollDueSources` von `findUnique({ sourceType:'doe-opendata' })` auf Fan-out um (13-RESEARCH.md Pattern 3): `const configs = await prisma.tenderSourcePollConfig.findMany({ where: { isActive: true } })`; `activePortalCount = configs.length`; `dedupActive = activePortalCount >= 2` (D-05). Iteriere die Configs: `const adapter = registry.get(config.sourceType as SourceType); if (!adapter) continue;` — je Config in `try { ... } catch (err) { this.logger.error(...) }` (catch-per-source, D-01: eine kaputte Quelle killt die anderen nicht). Der bestehende Day-Cursor-Gate (`nextDayToFetch`) + fetch + normalize bleiben PRO Quelle erhalten; ersetze den direkten `tender.upsert`-Block durch `dedup.resolve(normalized, { dedupActive })` und sammle `created`-Tender-IDs (Delta-Boundary D-07 bleibt: nur genuin neue IDs an `matching.matchDelta`). lastIngestedDay-Advance bleibt pro Config (Cursor je sourceType). Injiziere `SourceRegistry` und `TenderDedupService` in den Constructor; entferne die direkte `DoeOpenDataAdapter`-Nutzung im Poll-Pfad zugunsten `registry.get('doe-opendata')`. -Erweitere die bestehende `tender-ingestion.service.spec.ts`: (a) fan-out ueber mehrere aktive Configs; (b) catch-per-source (Quelle A wirft -> Quelle B laeuft trotzdem, Tick wirft nicht); (c) dedupActive-Gate (1 Config -> resolve mit dedupActive=false; 2 Configs -> dedupActive=true); (d) matchDelta bekommt nur created-IDs. Registry/Dedup im Test mocken. +Erweitere die bestehende `tender-ingestion.service.spec.ts`: (a) fan-out ueber mehrere aktive Configs; (b) catch-per-source (Quelle A wirft -> Quelle B laeuft trotzdem, Tick wirft nicht); (c) dedupActive-Gate (1 Config -> resolve mit dedupActive=false; 2 Configs -> dedupActive=true); (d) matchDelta bekommt nur created-IDs. Bestehende SCHEMA-02-Re-Poll-Tests muessen gruen bleiben (Change-Detection lebt jetzt in resolve, nicht mehr im direkten upsert) — falls ein Test den upsert-UPDATE direkt assertete, auf den resolve-Pfad umstellen. Registry/Dedup im Test mocken. pnpm --filter @tessera/api test -- tender-ingestion.service diff --git a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-VALIDATION.md b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-VALIDATION.md index 1af04c6..63c479a 100644 --- a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-VALIDATION.md +++ b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-VALIDATION.md @@ -43,12 +43,12 @@ Ein neues Package: `node-html-parser` (oder `cheerio`) — NUR nach blockierende | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| 13-01-01 | 01 | 1 | SCHEMA-03 | T-13-01-01/03 | TenderSource (1:n, @@unique[sourcePortal,sourceNoticeId]) + Tender.fingerprint additiv; 2-Schritt-Migration + Backfill lokal | integration | `cd apps/api && npx prisma validate && npx prisma generate` (+ psql-Count, manual) | ⚠️ migration | ⬜ pending | -| 13-01-02 | 01 | 1 | SCHEMA-03 | — | tenderFingerprint pure, NULL-tolerant (Wert/Frist NULL -> Match), kollisionsarm (Titel im Hash), sha256 | unit | `pnpm --filter @tessera/api test -- tender-fingerprint` | ❌ W0 | ⬜ pending | +| 13-01-01 | 01 | 1 | SCHEMA-03 | — | tenderFingerprint pure, NULL-tolerant (Wert/Frist NULL -> Match), kollisionsarm (Titel im Hash), sha256; steht vor Backfill bereit | unit | `pnpm --filter @tessera/api test -- tender-fingerprint` | ❌ W0 | ⬜ pending | +| 13-01-02 | 01 | 1 | SCHEMA-03 | T-13-01-01/03 | TenderSource (1:n, @@unique[sourcePortal,sourceNoticeId]) + Tender.fingerprint additiv; 2-Schritt-Migration + Backfill lokal (nutzt Task-1-fn) | integration | `cd apps/api && npx prisma validate && npx prisma generate` (+ psql-Count, manual) | ⚠️ migration | ⬜ pending | | 13-01-03 | 01 | 1 | SCHEMA-03 | — | SourceType-Union offen + NormalizedTenderFields.fingerprint; typecheck | typecheck | `cd apps/api && npx tsc --noEmit -p tsconfig.json` | n/a | ⬜ pending | | 13-02-01 | 02 | 1 | INGEST-07 | — | Adapter-Interface um portals[] generalisiert; DoeOpenDataAdapter deklariert portals | typecheck | `cd apps/api && npx tsc --noEmit -p tsconfig.json` | n/a | ⬜ pending | | 13-02-02 | 02 | 1 | INGEST-07 | T-13-02-01/02 | SourceRegistry.register wirft DeniedPortalError fuer vergabe24 UND aumass (Erfolgskriterium 4); legitime Registrierung/get funktionieren | unit | `pnpm --filter @tessera/api test -- source-registry` | ❌ W0 | ⬜ pending | -| 13-03-01 | 03 | 2 | SCHEMA-03 | T-13-03-02/03/04 | 3-Stufen-Resolver OCID→source:noticeId→fingerprint; inert bei 1 Quelle (D-05), Merge->TenderSource anhaengen ab 2 (D-03); plain Prisma (kein RLS) | unit | `pnpm --filter @tessera/api test -- tender-dedup` | ❌ W0 | ⬜ pending | +| 13-03-01 | 03 | 2 | SCHEMA-03 | T-13-03-02/03/04 | 3-Stufen-Resolver OCID→source:noticeId→fingerprint; inert bei 1 Quelle (D-05), Merge->TenderSource anhaengen ab 2 (D-03); plain Prisma (kein RLS); SCHEMA-02-Erhalt (Match+geaenderter contentHash -> tender.update mutable Felder, kein Phase-10-Regress) | unit | `pnpm --filter @tessera/api test -- tender-dedup` | ❌ W0 | ⬜ pending | | 13-03-02 | 03 | 2 | SCHEMA-03 | T-13-03-01/02 | pollDueSources fan-out (findMany isActive), catch-per-source, dedupActive=activePortalCount>=2, Delta-Boundary erhalten | unit | `pnpm --filter @tessera/api test -- tender-ingestion.service` | ⚠️ extend | ⬜ pending | | 13-03-03 | 03 | 2 | SCHEMA-03 | — | Modul-Wiring: SourceRegistry + TenderDedupService Provider + DoeOpenDataAdapter bei Boot registriert; DI aufloesbar | typecheck+suite | `cd apps/api && npx tsc --noEmit && pnpm --filter @tessera/api test -- src/tenders` | n/a | ⬜ pending | | 13-06-01 | 06 | 2 | SCHEMA-03 | T-13-06-03 | getTender include sources[]; Route-Order unveraendert (:id zuletzt) | integration | `pnpm --filter @tessera/api test -- tenders.controller` | ⚠️ extend | ⬜ pending | @@ -67,7 +67,7 @@ Ein neues Package: `node-html-parser` (oder `cheerio`) — NUR nach blockierende - [ ] `apps/api/src/tenders/tender-fingerprint.spec.ts` — SCHEMA-03 NULL-Toleranz (Wert+Frist NULL -> gleicher Fingerprint), Kollisions-Fall (anderer Titel -> anderer Hash), Umlaut-/CPV-Division-Normalisierung, valueBucket, deterministischer sha256 (Plan 13-01 Task 2, RED-first). - [ ] `apps/api/src/tenders/source-registry.spec.ts` — INGEST-07/Erfolgskriterium 4: register() wirft DeniedPortalError fuer vergabe24 UND aumass; gemischtes portals-Array abgelehnt; legitime Registrierung + get/activeAdapters (Plan 13-02 Task 2, RED-first). -- [ ] `apps/api/src/tenders/tender-dedup.service.spec.ts` — D-04/D-05: inert bei dedupActive=false (2 Tender), OCID-/noticeId-/Fingerprint-Match haengt TenderSource an (created=false), kein Match -> create (created=true); Prisma gemockt (Plan 13-03 Task 1, RED-first). +- [ ] `apps/api/src/tenders/tender-dedup.service.spec.ts` — D-04/D-05: inert bei dedupActive=false (2 Tender), OCID-/noticeId-/Fingerprint-Match haengt TenderSource an (created=false), kein Match -> create (created=true), SCHEMA-02-Erhalt (Match+geaenderter contentHash -> tender.update der mutable Felder); Prisma gemockt (Plan 13-03 Task 1, RED-first). - [ ] `apps/api/src/tenders/adapters/netserver.adapter.spec.ts` + `__fixtures__/netserver-search.html` — INGEST-02: parst live-gecapturte -Fixture -> RawTenderRecord[] mit korrektem sourcePortal je Zeile; []-Fallback bei kaputtem HTML; Fetch gemockt (Plan 13-04 Task 1). - [ ] `apps/api/src/tenders/adapters/cosinex.adapter.spec.ts` + `__fixtures__/cosinex-search.html` — INGEST-03: parst Satellite-Fixture ODER dokumentierter needs-JS-Leer-Fall; []-Fallback; Fetch gemockt (Plan 13-05 Task 1). - [ ] (extend) `apps/api/src/tenders/tender-ingestion.service.spec.ts` — fan-out ueber mehrere aktive Configs, catch-per-source (Quelle A wirft -> B laeuft), dedupActive-Gate, matchDelta nur mit created-IDs (Plan 13-03 Task 2).