183 lines
6.8 KiB
TypeScript
183 lines
6.8 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;
|
|
};
|
|
|
|
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.",
|
|
};
|
|
}
|