Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

View file

@ -0,0 +1,183 @@
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.",
};
}