import type { AssociationDirectoryEntry } from "../drizzle/schema"; export type AssociationDirectoryMatchInput = { nomAssociation?: string | null; email?: string | null; siret?: string | null; rna?: string | null; ville?: string | null; telephone?: string | null; nomRepresentant?: string | null; }; export type AssociationDirectoryMatchCandidate = Pick< AssociationDirectoryEntry, "id" | "nomAssociation" | "emailOfficiel" | "siret" | "rna" | "ville" | "telephone" | "nomRepresentant" >; export type AssociationDirectoryMatchResult = { status: "matched" | "ambiguous" | "none"; matchedEntry?: AssociationDirectoryMatchCandidate; candidates: AssociationDirectoryMatchCandidate[]; reason: string; }; function normalizeText(value?: string | null) { return String(value || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-zA-Z0-9]+/g, " ") .trim() .toLowerCase(); } export function normalizeDirectoryEmail(value?: string | null) { const trimmed = String(value || "").trim().toLowerCase(); return trimmed || null; } export function normalizeDirectorySiret(value?: string | null) { const digits = String(value || "").replace(/\D/g, ""); return digits || null; } export function normalizeDirectoryRna(value?: string | null) { const normalized = String(value || "").replace(/\s+/g, "").trim().toUpperCase(); return normalized || null; } export function normalizeDirectoryPhone(value?: string | null) { const digits = String(value || "").replace(/\D/g, ""); if (!digits) return null; return digits.length > 9 ? digits.slice(-9) : digits; } function candidateScore(candidate: AssociationDirectoryMatchCandidate, input: AssociationDirectoryMatchInput) { const localName = normalizeText(input.nomAssociation); const localCity = normalizeText(input.ville); const localEmail = normalizeDirectoryEmail(input.email); const localSiret = normalizeDirectorySiret(input.siret); const localRna = normalizeDirectoryRna(input.rna); const localPhone = normalizeDirectoryPhone(input.telephone); const localRepresentative = normalizeText(input.nomRepresentant); const candidateName = normalizeText(candidate.nomAssociation); const candidateCity = normalizeText(candidate.ville); const candidateEmail = normalizeDirectoryEmail(candidate.emailOfficiel); const candidateSiret = normalizeDirectorySiret(candidate.siret); const candidateRna = normalizeDirectoryRna(candidate.rna); const candidatePhone = normalizeDirectoryPhone(candidate.telephone); const candidateRepresentative = normalizeText(candidate.nomRepresentant); let score = 0; const exactSiret = Boolean(localSiret && candidateSiret && localSiret === candidateSiret); const exactRna = Boolean(localRna && candidateRna && localRna === candidateRna); const exactEmail = Boolean(localEmail && candidateEmail && localEmail === candidateEmail); const exactName = Boolean(localName && candidateName && localName === candidateName); const exactCity = Boolean(localCity && candidateCity && localCity === candidateCity); const exactPhone = Boolean(localPhone && candidatePhone && localPhone === candidatePhone); const exactRepresentative = Boolean( localRepresentative && candidateRepresentative && localRepresentative === candidateRepresentative ); if (exactSiret) score += 300; if (exactRna) score += 260; if (exactEmail) score += 220; if (exactPhone) score += 180; if (exactRepresentative) score += 140; if (exactName) score += 120; if (exactCity) score += 25; if (!exactName && localName && candidateName) { if (candidateName.includes(localName) || localName.includes(candidateName)) { score += 40; } } if (!exactRepresentative && localRepresentative && candidateRepresentative) { if ( candidateRepresentative.includes(localRepresentative) || localRepresentative.includes(candidateRepresentative) ) { score += 45; } } if (exactName && exactPhone) { score += 80; } if (exactName && exactRepresentative) { score += 60; } return { score, exactSiret, exactRna, exactEmail, exactName, exactCity, exactPhone, exactRepresentative }; } export function matchAssociationDirectoryEntry( entries: AssociationDirectoryMatchCandidate[], input: AssociationDirectoryMatchInput ): AssociationDirectoryMatchResult { const normalizedName = normalizeText(input.nomAssociation); const normalizedEmail = normalizeDirectoryEmail(input.email); const normalizedSiret = normalizeDirectorySiret(input.siret); const normalizedRna = normalizeDirectoryRna(input.rna); const normalizedPhone = normalizeDirectoryPhone(input.telephone); const normalizedRepresentative = normalizeText(input.nomRepresentant); const normalizedCity = normalizeText(input.ville); if (!normalizedName && !normalizedEmail && !normalizedSiret && !normalizedRna && !normalizedPhone && !normalizedRepresentative) { return { status: "none", candidates: [], reason: "Aucun identifiant exploitable n'a été fourni pour rechercher une fiche du bordereau.", }; } const exactSiret = entries.filter((entry) => normalizeDirectorySiret(entry.siret) === normalizedSiret && normalizedSiret); if (exactSiret.length === 1) { return { status: "matched", matchedEntry: exactSiret[0], candidates: exactSiret, reason: "Correspondance validée par le SIRET." }; } if (exactSiret.length > 1) { return { status: "ambiguous", candidates: exactSiret, reason: "Plusieurs fiches du bordereau portent le même SIRET." }; } const exactRna = entries.filter((entry) => normalizeDirectoryRna(entry.rna) === normalizedRna && normalizedRna); if (exactRna.length === 1) { return { status: "matched", matchedEntry: exactRna[0], candidates: exactRna, reason: "Correspondance validée par le RNA." }; } if (exactRna.length > 1) { return { status: "ambiguous", candidates: exactRna, reason: "Plusieurs fiches du bordereau portent le même RNA." }; } const exactEmail = entries.filter((entry) => normalizeDirectoryEmail(entry.emailOfficiel) === normalizedEmail && normalizedEmail); if (exactEmail.length === 1) { return { status: "matched", matchedEntry: exactEmail[0], candidates: exactEmail, reason: "Correspondance validée par l'email officiel." }; } if (exactEmail.length > 1) { return { status: "ambiguous", candidates: exactEmail, reason: "Plusieurs fiches du bordereau utilisent le même email officiel." }; } const exactPhone = entries.filter((entry) => normalizeDirectoryPhone(entry.telephone) === normalizedPhone && normalizedPhone); if (exactPhone.length === 1) { return { status: "matched", matchedEntry: exactPhone[0], candidates: exactPhone, reason: "Correspondance validée par le numéro de téléphone.", }; } if (exactPhone.length > 1) { return { status: "ambiguous", candidates: exactPhone, reason: "Plusieurs fiches du bordereau utilisent le même numéro de téléphone.", }; } const scored = entries .map((entry) => ({ entry, ...candidateScore(entry, input) })) .filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score); const exactNameAndRepresentative = scored.filter((entry) => entry.exactName && entry.exactRepresentative); if (exactNameAndRepresentative.length === 1) { return { status: "matched", matchedEntry: exactNameAndRepresentative[0].entry, candidates: exactNameAndRepresentative.map((entry) => entry.entry), reason: "Correspondance validée par le nom de l'association et du représentant.", }; } if (exactNameAndRepresentative.length > 1) { return { status: "ambiguous", candidates: exactNameAndRepresentative.map((entry) => entry.entry), reason: "Plusieurs fiches du bordereau correspondent au même nom d'association et représentant.", }; } const exactNameAndCity = scored.filter((entry) => entry.exactName && entry.exactCity); if (exactNameAndCity.length === 1) { return { status: "matched", matchedEntry: exactNameAndCity[0].entry, candidates: exactNameAndCity.map((entry) => entry.entry), reason: "Correspondance validée par le nom et la commune.", }; } if (exactNameAndCity.length > 1) { return { status: "ambiguous", candidates: exactNameAndCity.map((entry) => entry.entry), reason: "Plusieurs fiches du bordereau correspondent au même nom dans cette commune.", }; } const exactNameOnly = scored.filter((entry) => entry.exactName); if (exactNameOnly.length === 1) { return { status: "matched", matchedEntry: exactNameOnly[0].entry, candidates: exactNameOnly.map((entry) => entry.entry), reason: "Correspondance validée par le nom de l'association.", }; } if (exactNameOnly.length > 1) { return { status: "ambiguous", candidates: exactNameOnly.map((entry) => entry.entry), reason: "Plusieurs fiches du bordereau portent le même nom.", }; } const closeCandidates = scored .filter((entry) => entry.score >= 40) .map((entry) => entry.entry) .slice(0, 5); if (closeCandidates.length > 0) { return { status: "ambiguous", candidates: closeCandidates, reason: normalizedCity ? "Des rapprochements partiels ont été trouvés, mais aucun n'est assez fiable pour lier automatiquement cette association." : "Des rapprochements potentiels ont été trouvés, mais une validation humaine reste nécessaire.", }; } return { status: "none", candidates: [], reason: "Aucune fiche du bordereau ne correspond de façon fiable à cette association.", }; }