feat(13-03): implement TenderDedupService three-tier resolver (D-04/D-05)

resolve(n, {dedupActive}) matches OCID -> source:noticeId -> fingerprint
(fingerprint tier hard-gated by dedupActive, D-05). On any match the
existing Tender gets an additional TenderSource attached (D-03 merge)
instead of a new Tender row; SCHEMA-02 change-detection is preserved
inline (matched Tender's mutable fields refresh when contentHash
differs, exactly as the old direct tender.upsert UPDATE branch did).
No match -> tender.create (with computed fingerprint) + tenderSource.create.
Plain PrismaService, no forTenant()/RLS (T-10-09).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:48:12 +02:00
parent 605ea4cf80
commit 1cd2fcfa01
@@ -0,0 +1,190 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { tenderFingerprint } from './tender-fingerprint';
import type { NormalizedTenderFields } from './tender.types';
export interface DedupResolveOptions {
/**
* D-05 hard gate: the fingerprint tier only runs when true. Callers
* (pollDueSources, Plan 13-03 Task 2) compute this as
* `activePortalCount >= 2` — with only DÖE active, dedupActive is always
* false and the fingerprint tier NEVER runs (Erfolgskriterium 3, inert).
*/
dedupActive: boolean;
}
export interface DedupResolveResult {
tenderId: string;
created: boolean;
}
/** Minimal shape needed from a matched Tender row across all three tiers. */
interface TenderMatchRow {
id: string;
contentHash: string;
}
/**
* TenderDedupService — SCHEMA-03 three-tier cross-source dedup resolver
* (D-04): OCID -> source:noticeId -> fingerprint (13-RESEARCH Pattern 5).
* The fingerprint tier is hard-gated by `opts.dedupActive` (D-05) — with
* only one active source it is structurally never reached, so two
* fingerprint-equal DÖE tenders stay two separate Tender rows
* (Erfolgskriterium 3, inert).
*
* On a match (tiers 1-3): attaches an additional `TenderSource` to the
* EXISTING Tender (D-03 merge) instead of creating a new Tender row.
*
* SCHEMA-02 preservation (no Phase-10 regress): the old direct
* `tender.upsert` UPDATE branch in `pollDueSources` refreshed the mutable
* Tender fields (title, buyerName, cpvCodes, cpvDivisions, region, plz,
* bundesland, deadlineAt, estimatedValue, procedureType, sourceUrl,
* contentHash) whenever the contentHash changed on re-poll. That logic now
* lives HERE: on any match, if the incoming `contentHash` differs from the
* matched Tender's stored `contentHash`, the same mutable-field set is
* refreshed via `tender.update`. Only the create-vs-update DECISION moved
* into this resolver — the SCHEMA-02 semantics are unchanged.
*
* Plain, non-tenant-scoped PrismaService (T-10-09 pattern): Tender/
* TenderSource are platform-global, RLS-exempt tables. Never wrap these
* queries in forTenant().
*/
@Injectable()
export class TenderDedupService {
constructor(private readonly prisma: PrismaService) {}
async resolve(
n: NormalizedTenderFields,
opts: DedupResolveOptions,
): Promise<DedupResolveResult> {
const match = await this.findMatch(n, opts);
if (match) {
await this.prisma.tenderSource.upsert({
where: {
sourcePortal_sourceNoticeId: {
sourcePortal: n.sourcePortal,
sourceNoticeId: n.sourceNoticeId,
},
},
create: {
tenderId: match.id,
sourcePortal: n.sourcePortal,
sourceNoticeId: n.sourceNoticeId,
sourceUrl: n.sourceUrl,
ocid: n.ocid,
},
update: {
sourceUrl: n.sourceUrl,
},
});
// SCHEMA-02 change-detection preservation — see class doc.
if (n.contentHash !== match.contentHash) {
await this.prisma.tender.update({
where: { id: match.id },
data: {
title: n.title,
buyerName: n.buyerName,
cpvCodes: n.cpvCodes,
cpvDivisions: n.cpvDivisions,
region: n.region,
plz: n.plz,
bundesland: n.bundesland,
deadlineAt: n.deadlineAt,
estimatedValue: n.estimatedValue,
procedureType: n.procedureType,
sourceUrl: n.sourceUrl,
contentHash: n.contentHash,
},
});
}
return { tenderId: match.id, created: false };
}
const fingerprint = n.fingerprint ?? tenderFingerprint(n);
const created = await this.prisma.tender.create({
data: {
sourcePortal: n.sourcePortal,
sourceNoticeId: n.sourceNoticeId,
ocid: n.ocid,
dedupKey: n.dedupKey,
title: n.title,
buyerName: n.buyerName,
cpvCodes: n.cpvCodes,
cpvDivisions: n.cpvDivisions,
region: n.region,
plz: n.plz,
bundesland: n.bundesland,
deadlineAt: n.deadlineAt,
estimatedValue: n.estimatedValue,
procedureType: n.procedureType,
status: n.status,
sourceUrl: n.sourceUrl,
contentHash: n.contentHash,
publishedAt: n.publishedAt,
fingerprint,
},
});
await this.prisma.tenderSource.create({
data: {
tenderId: created.id,
sourcePortal: n.sourcePortal,
sourceNoticeId: n.sourceNoticeId,
sourceUrl: n.sourceUrl,
ocid: n.ocid,
},
});
return { tenderId: created.id, created: true };
}
private async findMatch(
n: NormalizedTenderFields,
opts: DedupResolveOptions,
): Promise<TenderMatchRow | null> {
// Tier 1: OCID (D-04 primary key, present for DÖE — 0% NULL live).
if (n.ocid) {
const byOcid = await this.prisma.tender.findFirst({
where: { ocid: n.ocid },
select: { id: true, contentHash: true },
});
if (byOcid) return byOcid;
}
// Tier 2: source:noticeId — exact same source re-seen (idempotent
// re-poll). NOT gated by dedupActive — this is the same-source
// identity check, independent of cross-source merging.
const existingSource = await this.prisma.tenderSource.findUnique({
where: {
sourcePortal_sourceNoticeId: {
sourcePortal: n.sourcePortal,
sourceNoticeId: n.sourceNoticeId,
},
},
select: { tenderId: true },
});
if (existingSource) {
const byNoticeId = await this.prisma.tender.findUnique({
where: { id: existingSource.tenderId },
select: { id: true, contentHash: true },
});
if (byNoticeId) return byNoticeId;
}
// Tier 3: fingerprint — ONLY when dedupActive (D-05). With a single
// active source this branch is structurally never reached.
if (opts.dedupActive) {
const fingerprint = n.fingerprint ?? tenderFingerprint(n);
const byFingerprint = await this.prisma.tender.findFirst({
where: { fingerprint },
select: { id: true, contentHash: true },
});
if (byFingerprint) return byFingerprint;
}
return null;
}
}