portail-associations/server/associationReferenceSync.ts

402 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
import { ENV } from "./_core/env";
export type ReferenceProposalChange = {
field: string;
label: string;
currentValue: string;
proposedValue: string;
};
export type ReferenceProposalResult = {
hasChanges: boolean;
updates: Partial<InsertAssociationDirectoryEntry>;
changes: ReferenceProposalChange[];
sourceLabel: string | null;
summary: string;
};
type DjepvaAssociationResponse = {
data?: {
association?: {
rna?: string | null;
siret_siege?: string | null;
active?: boolean | null;
date_creation?: string | null;
objet?: string | null;
adresse_siege?: {
code_postal?: string | null;
commune?: string | null;
numero_voie?: string | null;
type_voie?: string | null;
libelle_voie?: string | null;
} | null;
forme_juridique?: {
libelle?: string | null;
} | null;
reconnue_utilite_publique?: boolean | null;
} | null;
meta?: {
date_derniere_mise_a_jour_sirene?: string | null;
date_derniere_mise_a_jour_rna?: string | null;
} | null;
} | null;
};
type SearchApiResult = {
siren?: string | null;
nom_complet?: string | null;
nom_raison_sociale?: string | null;
date_creation?: string | null;
date_mise_a_jour?: string | null;
date_mise_a_jour_insee?: string | null;
etat_administratif?: string | null;
nature_juridique?: string | null;
siege?: {
siret?: string | null;
adresse?: string | null;
code_postal?: string | null;
libelle_commune?: string | null;
} | null;
complements?: {
identifiant_association?: string | null;
est_association?: boolean | null;
} | null;
};
type SearchApiResponse = {
results?: SearchApiResult[];
};
function normalizeText(value: string | null | undefined) {
return (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
function cleanDigits(value: string | null | undefined) {
return (value || "").replace(/\D/g, "");
}
function normalizeRna(value: string | null | undefined) {
const trimmed = (value || "").trim().toUpperCase();
return /^W\d{9}$/.test(trimmed) ? trimmed : null;
}
function normalizeSiret(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length === 14 ? digits : null;
}
function normalizeSiren(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length === 9 ? digits : null;
}
function parseDate(value: string | null | undefined) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function maxDate(...values: Array<Date | null>) {
const dates = values.filter((value): value is Date => Boolean(value));
if (dates.length === 0) return null;
return new Date(Math.max(...dates.map((date) => date.getTime())));
}
function buildAddressLabel(parts: Array<string | null | undefined>) {
const cleaned = parts.map((part) => (part || "").trim()).filter(Boolean);
return cleaned.length > 0 ? cleaned.join(" ") : null;
}
function mapAssociationStatus(active: boolean | null | undefined, etatAdministratif?: string | null) {
if (typeof active === "boolean") {
return active ? "active" : "inactive";
}
if (etatAdministratif === "A") return "active";
if (etatAdministratif === "C" || etatAdministratif === "F") return "inactive";
return null;
}
function mapStatutJuridique(libelle?: string | null, reconnueUtilitePublique?: boolean | null) {
const normalized = normalizeText(libelle);
if (reconnueUtilitePublique) {
return "association_reconnue_utilite_publique" as const;
}
if (normalized.includes("fondation")) {
return "fondation" as const;
}
if (normalized.includes("association")) {
return "association_loi_1901" as const;
}
return null;
}
async function fetchJson<T>(url: URL | string, init?: RequestInit) {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json() as T;
}
async function fetchDjepvaAssociation(identifier: string) {
if (!ENV.entrepriseApiToken) {
return null;
}
const url = `https://entreprise.api.gouv.fr/v4/djepva/api-association/associations/${encodeURIComponent(identifier)}`;
try {
return await fetchJson<DjepvaAssociationResponse>(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${ENV.entrepriseApiToken}`,
"User-Agent": "portail-associations/1.0",
},
});
} catch {
return null;
}
}
function scoreSearchResult(result: SearchApiResult, targetName: string, targetCity?: string | null) {
let score = 0;
const resultName = normalizeText(result.nom_raison_sociale || result.nom_complet);
const normalizedTargetCity = normalizeText(targetCity);
const resultCity = normalizeText(result.siege?.libelle_commune);
if (result.complements?.est_association) score += 10;
if (resultName === targetName) score += 100;
else if (resultName.includes(targetName) || targetName.includes(resultName)) score += 40;
if (normalizedTargetCity && resultCity && normalizedTargetCity === resultCity) score += 20;
if (result.siege?.siret) score += 5;
return score;
}
async function searchAssociationInSirene(entry: AssociationDirectoryEntry, association?: Association | null) {
const knownSiret = normalizeSiret(association?.siret || entry.siret);
const knownSiren = normalizeSiren(knownSiret ? knownSiret.slice(0, 9) : association?.siret || entry.siret);
const query = knownSiret || knownSiren || association?.nomAssociation || entry.nomAssociation;
if (!query) return null;
const url = new URL("https://recherche-entreprises.api.gouv.fr/search");
url.searchParams.set("q", query);
url.searchParams.set("page", "1");
url.searchParams.set("per_page", "10");
url.searchParams.set("est_association", "true");
if (!knownSiret && !knownSiren) {
const codePostal = association?.codePostal || entry.codePostal;
if (codePostal) {
url.searchParams.set("code_postal", codePostal);
}
}
try {
const payload = await fetchJson<SearchApiResponse>(url, {
headers: {
Accept: "application/json",
"User-Agent": "portail-associations/1.0",
},
});
const results = payload.results || [];
if (results.length === 0) return null;
if (knownSiret) {
return results.find((result) => normalizeSiret(result.siege?.siret) === knownSiret) || results[0];
}
if (knownSiren) {
return results.find((result) => normalizeSiren(result.siren) === knownSiren) || results[0];
}
const targetName = normalizeText(association?.nomAssociation || entry.nomAssociation);
const targetCity = association?.ville || entry.ville;
return [...results].sort((a, b) => scoreSearchResult(b, targetName, targetCity) - scoreSearchResult(a, targetName, targetCity))[0];
} catch {
return null;
}
}
export async function computeAssociationDirectoryReferenceUpdate(
entry: AssociationDirectoryEntry,
association?: Association | null
) {
const knownRna = normalizeRna(association?.rna || entry.rna);
const knownSiret = normalizeSiret(association?.siret || entry.siret);
const knownSiren = normalizeSiren(knownSiret ? knownSiret.slice(0, 9) : association?.siret || entry.siret);
const djepvaIdentifier = knownRna || knownSiren;
const [djepva, searchResult] = await Promise.all([
djepvaIdentifier ? fetchDjepvaAssociation(djepvaIdentifier) : Promise.resolve(null),
searchAssociationInSirene(entry, association),
]);
const djepvaAssociation = djepva?.data?.association || null;
const djepvaMeta = djepva?.data?.meta || null;
const resolvedRna =
normalizeRna(djepvaAssociation?.rna) ||
normalizeRna(searchResult?.complements?.identifiant_association) ||
knownRna;
const resolvedSiret =
normalizeSiret(djepvaAssociation?.siret_siege) ||
normalizeSiret(searchResult?.siege?.siret) ||
knownSiret;
const resolvedStatus =
mapAssociationStatus(djepvaAssociation?.active, searchResult?.etat_administratif) ||
entry.associationStatus ||
null;
const registryLastUpdatedAt = maxDate(
parseDate(djepvaMeta?.date_derniere_mise_a_jour_rna),
parseDate(djepvaMeta?.date_derniere_mise_a_jour_sirene),
parseDate(searchResult?.date_mise_a_jour_insee),
parseDate(searchResult?.date_mise_a_jour)
);
const inferredDateCreation =
parseDate(djepvaAssociation?.date_creation) ||
parseDate(searchResult?.date_creation) ||
entry.dateCreation ||
null;
const inferredAddress =
buildAddressLabel([
djepvaAssociation?.adresse_siege?.numero_voie,
djepvaAssociation?.adresse_siege?.type_voie,
djepvaAssociation?.adresse_siege?.libelle_voie,
]) ||
searchResult?.siege?.adresse ||
entry.adresse ||
null;
const inferredCodePostal =
djepvaAssociation?.adresse_siege?.code_postal ||
searchResult?.siege?.code_postal ||
entry.codePostal ||
null;
const inferredVille =
djepvaAssociation?.adresse_siege?.commune ||
searchResult?.siege?.libelle_commune ||
entry.ville ||
null;
const resolvedStatutJuridique =
mapStatutJuridique(djepvaAssociation?.forme_juridique?.libelle, djepvaAssociation?.reconnue_utilite_publique) ||
entry.statutJuridique ||
null;
const sourceLabels = [
djepvaAssociation ? "API RNA (DJEPVA)" : null,
searchResult ? "API SIRENE / Recherche dentreprises" : null,
].filter(Boolean);
const hasAnyReference = Boolean(resolvedRna || resolvedSiret || resolvedStatus || registryLastUpdatedAt);
const updates: Partial<InsertAssociationDirectoryEntry> = {
rna: resolvedRna,
siret: resolvedSiret,
associationStatus: resolvedStatus,
registryLastUpdatedAt,
referenceLastCheckedAt: new Date(),
referenceStatus: hasAnyReference ? "reference_data_synced" : "reference_data_unresolved",
referenceSourceLabel: sourceLabels.length > 0 ? sourceLabels.join(" + ") : null,
dateCreation: inferredDateCreation,
adresse: inferredAddress,
codePostal: inferredCodePostal,
ville: inferredVille,
};
if (resolvedStatutJuridique) {
updates.statutJuridique = resolvedStatutJuridique;
}
if (djepvaAssociation?.objet && !entry.objetAssociation) {
updates.objetAssociation = djepvaAssociation.objet;
}
return updates;
}
const proposalFieldLabels: Record<string, string> = {
rna: "RNA",
siret: "SIRET",
associationStatus: "Statut",
dateCreation: "Date de création",
adresse: "Adresse",
codePostal: "Code postal",
ville: "Ville",
statutJuridique: "Statut juridique",
objetAssociation: "Objet de l'association",
};
function normalizeComparableDate(value: unknown) {
if (!value) return "";
const date = value instanceof Date ? value : new Date(String(value));
if (Number.isNaN(date.getTime())) return "";
return date.toISOString().slice(0, 10);
}
function normalizeComparableValue(field: string, value: unknown) {
if (value == null) return "";
if (field.toLowerCase().includes("date")) {
return normalizeComparableDate(value);
}
if (typeof value === "string") {
return value.trim();
}
return String(value);
}
function formatProposalValue(field: string, value: unknown) {
if (value == null || value === "") return "Non renseigné";
if (field.toLowerCase().includes("date")) {
const normalized = normalizeComparableDate(value);
return normalized || "Non renseigné";
}
return String(value);
}
export async function computeAssociationDirectoryReferenceProposal(
entry: AssociationDirectoryEntry,
association?: Association | null
): Promise<ReferenceProposalResult> {
const updates = await computeAssociationDirectoryReferenceUpdate(entry, association);
const proposalFields = Object.keys(proposalFieldLabels);
const changes: ReferenceProposalChange[] = proposalFields
.filter((field) => field in updates)
.map((field) => {
const currentValue = normalizeComparableValue(field, (entry as Record<string, unknown>)[field]);
const proposedValue = normalizeComparableValue(field, (updates as Record<string, unknown>)[field]);
if (!proposedValue || currentValue === proposedValue) {
return null;
}
return {
field,
label: proposalFieldLabels[field],
currentValue: formatProposalValue(field, (entry as Record<string, unknown>)[field]),
proposedValue: formatProposalValue(field, (updates as Record<string, unknown>)[field]),
};
})
.filter(Boolean) as ReferenceProposalChange[];
const sourceLabel = updates.referenceSourceLabel || null;
const summary = changes.length
? `${changes.length} champ(s) à revoir : ${changes.slice(0, 4).map((change) => change.label).join(", ")}${changes.length > 4 ? "…" : ""}`
: sourceLabel
? `Aucune différence utile détectée malgré une lecture via ${sourceLabel}.`
: "Aucune donnée de référence exploitable n'a été trouvée.";
return {
hasChanges: changes.length > 0,
updates,
changes,
sourceLabel,
summary,
};
}