Kernlogik, Mandantentrennung (Mandant B liest/ändert nichts von A) und Rollen/Scope (Monteur ohne Zuweisung → not_found/forbidden). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
7.1 KiB
TypeScript
118 lines
7.1 KiB
TypeScript
// L1 Stammdaten — Dublettenprüfung (Spec §7.3, US-003):
|
|
// (1) Normalisierung: Kleinschreibung, Umlaute, Rechtsformen, Straße/Str., Telefon nur Ziffern
|
|
// (2) Scoring: Kundennummer, Name, Adresse, E-Mail, Telefon; Schwelle
|
|
// (3) findDuplicateCustomers gegen die DB inkl. Mandantentrennung und Monteur-Scope
|
|
//
|
|
// Lauf: npx tsx scripts/test-stammdaten-duplicates.ts
|
|
|
|
import "dotenv/config"; // must run before any module that constructs the Prisma client
|
|
|
|
import {
|
|
DUPLICATE_THRESHOLD,
|
|
nameSimilarity,
|
|
normalizeCompanyName,
|
|
normalizePhone,
|
|
normalizeStreet,
|
|
normalizeText,
|
|
scoreDuplicate,
|
|
} from "../src/lib/customers/duplicates";
|
|
import { findDuplicateCustomers } from "../src/server/services/customers/duplicates";
|
|
import { checker, cleanupTenants, createTenant, createUser, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures";
|
|
|
|
const SLUG_A = "zz-l1-dup-a";
|
|
const SLUG_B = "zz-l1-dup-b";
|
|
const DOMAIN = "zz-l1-dup.test";
|
|
const c = checker("Dublettenprüfung");
|
|
|
|
async function main() {
|
|
console.log("— (1) Normalisierung —");
|
|
c.ok(normalizeText(" ÄÖÜ ßtraße ") === "aeoeue sstrasse", "Umlaute/ß transliteriert, getrimmt, klein");
|
|
c.ok(normalizeCompanyName("Müller Haustechnik GmbH & Co. KG") === "mueller haustechnik", "Rechtsform „GmbH & Co. KG“ entfernt");
|
|
c.ok(normalizeCompanyName("MUELLER Haustechnik AG") === "mueller haustechnik", "Rechtsform „AG“ entfernt, Groß/Klein egal");
|
|
c.ok(normalizeCompanyName("Bauer e.K.") === "bauer", "Rechtsform „e.K.“ entfernt");
|
|
c.ok(normalizeCompanyName("AGRAR Service") === "agrar service", "„AG“ innerhalb eines Wortes bleibt erhalten");
|
|
c.ok(normalizeStreet("Hafenstraße") === "hafenstr" && normalizeStreet("Hafen-Str.") === "hafenstr" && normalizeStreet("Hafen Strasse") === "hafenstr", "Straße/Str./Strasse vereinheitlicht");
|
|
c.ok(normalizePhone("+49 40 123456-0") === "0401234560" && normalizePhone("040 / 123 456 0") === "0401234560", "Telefon nur Ziffern, +49 → 0");
|
|
c.ok(normalizePhone("12") === "", "zu kurze Nummern werden ignoriert");
|
|
|
|
console.log("\n— (2) Scoring —");
|
|
const existing = {
|
|
customerNumber: "K-00042",
|
|
companyName: "Müller Haustechnik GmbH",
|
|
street: "Hafenstraße",
|
|
houseNumber: "12",
|
|
postalCode: "20457",
|
|
email: "info@mueller.example",
|
|
phone: "+49 40 123456-0",
|
|
};
|
|
const byNumber = scoreDuplicate({ customerNumber: "k-00042" }, existing);
|
|
c.ok(byNumber.score === 1 && byNumber.reasons.includes("customer_number"), "gleiche Kundennummer → Score 1");
|
|
const byNameAddress = scoreDuplicate({ companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }, existing);
|
|
c.ok(Math.abs(byNameAddress.score - 0.76) < 0.001 && byNameAddress.reasons.join() === "company_name,address", `Name + Adresse → 0.76 (${byNameAddress.score})`);
|
|
const byEmail = scoreDuplicate({ email: "INFO@mueller.example " }, existing);
|
|
c.ok(byEmail.score === 0.6 && byEmail.reasons.join() === "email", "E-Mail (Groß/Klein, Leerzeichen) → 0.6");
|
|
const byPhone = scoreDuplicate({ mobile: "040 1234560" }, existing);
|
|
c.ok(byPhone.score === 0.5 && byPhone.reasons.join() === "phone", "Telefon gegen Mobil-Eingabe → 0.5");
|
|
const similar = scoreDuplicate({ companyName: "Müller Haustechnick" }, existing);
|
|
c.ok(similar.reasons.includes("company_name") && similar.score === 0.45, `ähnlicher Name (Tippfehler) → 0.45 (${similar.score})`);
|
|
c.ok(nameSimilarity("abc", "xyz") === 0, "unähnliche Namen → Ähnlichkeit 0");
|
|
const unrelated = scoreDuplicate({ companyName: "Nordlicht Elektro", postalCode: "10115", street: "Invalidenstraße" }, existing);
|
|
c.ok(unrelated.score === 0 && unrelated.score < DUPLICATE_THRESHOLD, "fremder Kunde → Score 0 (unter Schwelle)");
|
|
const all = scoreDuplicate({ ...existing }, existing);
|
|
c.ok(all.score === 1 && all.reasons.length === 5, "alle Merkmale gleich → 5 Gründe, Score 1");
|
|
|
|
console.log("\n— (3) findDuplicateCustomers (DB) —");
|
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
|
const tA = await createTenant(SLUG_A, "L1 Dubletten A");
|
|
const tB = await createTenant(SLUG_B, "L1 Dubletten B");
|
|
const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A");
|
|
const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A");
|
|
const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B");
|
|
|
|
const custA = await prisma.customer.create({ data: { tenantId: tA.id, ...existing } });
|
|
const otherA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00043", companyName: "Schmidt Bedachung", city: "Kiel", postalCode: "24103" } });
|
|
const mergedA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00044", companyName: "Müller Haustechnik", status: "merged" } });
|
|
const custB = await prisma.customer.create({ data: { tenantId: tB.id, ...existing } });
|
|
|
|
const ctxA = ctxFor(tA.id, boA.id, "backoffice");
|
|
const ctxB = ctxFor(tB.id, boB.id, "backoffice");
|
|
|
|
const hits = await findDuplicateCustomers(ctxA, { companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" });
|
|
c.ok(hits.length === 1 && hits[0].customerId === custA.id, "Treffer in Mandant A gefunden (Name + Adresse)");
|
|
c.ok(hits[0]?.score >= 0.76 && hits[0]?.displayName === existing.companyName, "Treffer mit Score und Anzeigename");
|
|
c.ok(!hits.some((h) => h.customerId === custB.id), "Mandantentrennung: gleicher Kunde in Mandant B wird NICHT gefunden");
|
|
c.ok(!hits.some((h) => h.customerId === mergedA.id), "zusammengeführte Kunden werden nicht vorgeschlagen");
|
|
c.ok(!hits.some((h) => h.customerId === otherA.id), "unähnlicher Kunde nicht vorgeschlagen");
|
|
|
|
const phoneHits = await findDuplicateCustomers(ctxA, { phone: "040 1234560" });
|
|
c.ok(phoneHits.length === 1 && phoneHits[0].customerId === custA.id && phoneHits[0].reasons.join() === "phone", "Treffer nur über formatiert gespeicherte Telefonnummer");
|
|
|
|
const numberHits = await findDuplicateCustomers(ctxA, { customerNumber: "k-00042" });
|
|
c.ok(numberHits[0]?.customerId === custA.id && numberHits[0]?.score === 1, "Treffer über Kundennummer (Score 1)");
|
|
|
|
const excluded = await findDuplicateCustomers(ctxA, { customerNumber: "K-00042" }, { excludeId: custA.id });
|
|
c.ok(excluded.length === 0, "excludeId schließt den eigenen Datensatz aus");
|
|
|
|
const hitsB = await findDuplicateCustomers(ctxB, { customerNumber: "K-00043" });
|
|
c.ok(hitsB.length === 0, "Mandant B findet Kunden von A nicht über deren Kundennummer");
|
|
|
|
const techHits = await findDuplicateCustomers(ctxFor(tA.id, techA.id, "technician"), { customerNumber: "K-00042" });
|
|
c.ok(techHits.length === 0, "Monteur ohne sichtbaren Auftrag erhält keine Kundentreffer (Scope)");
|
|
|
|
c.ok((await findDuplicateCustomers(ctxA, {})).length === 0, "leerer Kandidat → keine Treffer");
|
|
|
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
|
}
|
|
|
|
main()
|
|
.catch(async (err) => {
|
|
console.error(err);
|
|
c.ok(false, "unerwarteter Fehler");
|
|
})
|
|
.finally(async () => {
|
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch(() => {});
|
|
const failures = c.finish();
|
|
await disconnect();
|
|
process.exit(failures === 0 ? 0 : 1);
|
|
});
|