import type { AssociationDirectoryEntry } from "../drizzle/schema"; export type AssociationDirectoryMatchInput = { nomAssociation?: string | null; email?: string | null; siret?: string | null; rna?: string | null; ville?: string | null; }; export type AssociationDirectoryMatchCandidate = Pick< AssociationDirectoryEntry, "id" | "nomAssociation" | "emailOfficiel" | "siret" | "rna" | "ville" >; 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; } 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 candidateName = normalizeText(candidate.nomAssociation); const candidateCity = normalizeText(candidate.ville); const candidateEmail = normalizeDirectoryEmail(candidate.emailOfficiel); const candidateSiret = normalizeDirectorySiret(candidate.siret); const candidateRna = normalizeDirectoryRna(candidate.rna); 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); if (exactSiret) score += 300; if (exactRna) score += 260; if (exactEmail) score += 220; if (exactName) score += 120; if (exactCity) score += 25; if (!exactName && localName && candidateName) { if (candidateName.includes(localName) || localName.includes(candidateName)) { score += 40; } } return { score, exactSiret, exactRna, exactEmail, exactName, exactCity }; } 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 normalizedCity = normalizeText(input.ville); if (!normalizedName && !normalizedEmail && !normalizedSiret && !normalizedRna) { 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 scored = entries .map((entry) => ({ entry, ...candidateScore(entry, input) })) .filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score); 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.", }; }