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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user