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