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