portail-associations/server/associationDirectoryMatcher.ts

254 lines
9.5 KiB
TypeScript

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.",
};
}