| 13-scraping-adapters-cross-source-dedup |
03 |
backend |
| nestjs |
| dedup |
| prisma |
| fan-out |
| error-isolation |
|
| phase |
plan |
provides |
| 13-scraping-adapters-cross-source-dedup |
01 |
tenderFingerprint(), TenderSource model, Tender.fingerprint, SourceType open union |
|
| phase |
plan |
provides |
| 13-scraping-adapters-cross-source-dedup |
02 |
SourceRegistry (get/activeAdapters), TenderSourceAdapter.portals[], DeniedPortalError denylist gate |
|
|
| TenderDedupService.resolve(n, {dedupActive}) — three-tier cross-source dedup resolver (OCID -> source:noticeId -> fingerprint), fingerprint tier hard-gated by dedupActive (D-05) |
| pollDueSources fan-out over ALL active TenderSourcePollConfig rows (findMany, not findUnique) with catch-per-source error isolation |
| SourceRegistry + TenderDedupService registered as TendersModule providers; DoeOpenDataAdapter registered with the registry at DI boot (onModuleInit) |
|
| 13-04-netserver-adapter |
| 13-05-cosinex-adapter |
| 13-06-multi-source-display |
|
| added |
patterns |
|
|
| Dedup resolver owns both the create-vs-attach decision AND the SCHEMA-02 mutable-field refresh — the old direct tender.upsert UPDATE branch moved into resolve()'s match path unchanged in semantics |
| dedupActive computed once per tick from configs.length (activePortalCount >= 2) and threaded through every resolve() call in the fan-out loop |
| catch-per-source: each config's day-cursor-gate/fetch/normalize/resolve block runs in its own try/catch inside the fan-out for-loop, nested inside the existing outer tick-level try/catch |
|
|
| created |
modified |
| apps/api/src/tenders/tender-dedup.service.ts |
| apps/api/src/tenders/tender-dedup.service.spec.ts |
|
| apps/api/src/tenders/tender-ingestion.service.ts |
| apps/api/src/tenders/tender-ingestion.service.spec.ts |
| apps/api/src/tenders/tenders.module.ts |
|
|
| Tier 2 (source:noticeId via TenderSource lookup) is NOT gated by dedupActive — it is the same-source re-poll identity check, independent from cross-source merging, and must remain active even with a single source (idempotent re-poll). |
| Fingerprint is always computed and stored on tender.create (n.fingerprint ?? tenderFingerprint(n)), regardless of dedupActive — a DÖE-only tender still gets a fingerprint so it becomes matchable the moment a 2nd source activates, without a further backfill. |
| SCHEMA-02 mutable-field list is copied verbatim from the pre-existing tender.upsert UPDATE block (title, buyerName, cpvCodes, cpvDivisions, region, plz, bundesland, deadlineAt, estimatedValue, procedureType, sourceUrl, contentHash) into resolve()'s match branch, gated by a contentHash comparison instead of always running on every upsert call — functionally identical outcome, explicit no-op test added. |
| pruneExpiredTenders() runs once per tick (not once per source) — anyDayFetched is now an across-all-sources flag, matching the pre-existing once-per-tick retention semantics. |
|
| Fake TenderDedupService.resolve() in tender-ingestion.service.spec.ts mirrors the real service's dedupKey-upsert contract (created vs. updated) without re-testing tier logic — keeps the fan-out spec focused on orchestration, not dedup internals (covered separately by tender-dedup.service.spec.ts). |
|
|
| id |
description |
requirement |
verification |
human_judgment |
| D1 |
dedupActive=false: two normalized records with the same fingerprint but different source:noticeId create TWO Tender rows (fingerprint tier skipped, D-05 inert-proof, Erfolgskriterium 3) |
SCHEMA-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/tender-dedup.service.spec.ts — 'creates TWO Tender rows for records with the same fingerprint but different source:noticeId, when dedupActive=false' |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D2 |
dedupActive=true: OCID match, idempotent source:noticeId re-poll, and fingerprint match all attach an additional TenderSource to the existing Tender (created=false, D-03 merge); tenderSource.upsert targets the @@unique([sourcePortal, sourceNoticeId]) composite key |
SCHEMA-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/tender-dedup.service.spec.ts — OCID-match/idempotent-reseen/fingerprint-match/no-match/composite-key tests (8/8 pass) |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D3 |
SCHEMA-02 change-detection preserved through resolve(): a matched Tender whose incoming contentHash differs gets its mutable fields (title, deadlineAt, contentHash, etc.) updated; an unchanged contentHash does NOT call tender.update — no Phase-10 regression |
SCHEMA-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/tender-dedup.service.spec.ts — 'OCID match with a changed contentHash still updates...' + 'OCID match with an UNCHANGED contentHash does NOT call tender.update' |
pass |
|
| kind |
ref |
status |
| unit |
apps/api/src/tenders/tender-ingestion.service.spec.ts — existing SCHEMA-02 re-poll suite (insert-once, update-in-place-on-changed-hash) kept green through the resolve() hand-off |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D4 |
pollDueSources fans out over ALL active TenderSourcePollConfig rows (findMany, not findFirst/findUnique) with catch-per-source error isolation — one broken/blocking source does not abort polling of the others, nor does it throw out of the tick |
SCHEMA-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/tender-ingestion.service.spec.ts — 'polls every active config...', 'skips a config whose sourceType has no registered adapter, without throwing', 'a throwing source does not prevent another active source from being polled' (14/14 pass) |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D5 |
dedupActive is bound to activePortalCount >= 2 and threaded into every resolve() call; delta-only matchDelta boundary (D-07) preserved across the fan-out (only genuinely-created IDs across all sources) |
SCHEMA-03 |
| kind |
ref |
status |
| unit |
apps/api/src/tenders/tender-ingestion.service.spec.ts — 'passes dedupActive=false...single config', 'passes dedupActive=true...two configs', existing D-07 delta-only suite kept green |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D6 |
SourceRegistry + TenderDedupService are TendersModule providers; DoeOpenDataAdapter is registered with the registry in onModuleInit (denylist gate applies at DI boot); DI graph resolves cleanly |
SCHEMA-03 |
| kind |
ref |
status |
| unit |
cd apps/api && npx tsc --noEmit -p tsconfig.json (clean) |
pass |
|
| kind |
ref |
status |
| unit |
cd apps/api && npx vitest run src/tenders (19 files, 190/190 pass) |
pass |
|
| kind |
ref |
status |
| unit |
cd apps/api && npx vitest run (full suite, 23 files, 253/253 pass) |
pass |
|
|
false |
|
|
45min |
2026-07-23 |
complete |