402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
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 d’entreprises" : 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,
|
||
};
|
||
}
|