Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
402
server/associationReferenceSync.ts
Normal file
402
server/associationReferenceSync.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue