france-data-mcp 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +100 -0
- package/README.md +137 -0
- package/dist/cli.js +127 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1674 -0
- package/dist/index.js.map +1 -0
- package/dist/sante/index.d.ts +1053 -0
- package/dist/sante/index.js +1566 -0
- package/dist/sante/index.js.map +1 -0
- package/dist/territoire/index.d.ts +153 -0
- package/dist/territoire/index.js +249 -0
- package/dist/territoire/index.js.map +1 -0
- package/dist/types-6cvLQmuz.d.ts +68 -0
- package/package.json +105 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, rename, unlink, stat } from 'fs/promises';
|
|
2
|
+
import { createReadStream, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { createClient } from '@supabase/supabase-js';
|
|
6
|
+
|
|
7
|
+
// src/core/http.ts
|
|
8
|
+
var DEFAULT_USER_AGENT = "france-data-mcp/0.1.0 (+https://github.com/cturkieh/france-data-mcp)";
|
|
9
|
+
var HttpError = class extends Error {
|
|
10
|
+
constructor(message, status, url, body) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.url = url;
|
|
14
|
+
this.body = body;
|
|
15
|
+
this.name = "HttpError";
|
|
16
|
+
}
|
|
17
|
+
status;
|
|
18
|
+
url;
|
|
19
|
+
body;
|
|
20
|
+
};
|
|
21
|
+
var RateLimitExceededError = class extends HttpError {
|
|
22
|
+
constructor(url, retryAfter) {
|
|
23
|
+
super(`Rate limit exceeded after retries on ${url} (retry-after: ${retryAfter}s)`, 429, url);
|
|
24
|
+
this.name = "RateLimitExceededError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
async function fetchJson(url, options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
maxRetries = 3,
|
|
30
|
+
baseDelayMs = 500,
|
|
31
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
32
|
+
headers = {},
|
|
33
|
+
signal
|
|
34
|
+
} = options;
|
|
35
|
+
let lastError;
|
|
36
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
headers: {
|
|
40
|
+
Accept: "application/json",
|
|
41
|
+
"User-Agent": userAgent,
|
|
42
|
+
...headers
|
|
43
|
+
},
|
|
44
|
+
signal
|
|
45
|
+
});
|
|
46
|
+
if (response.ok) {
|
|
47
|
+
return await response.json();
|
|
48
|
+
}
|
|
49
|
+
if (response.status === 429) {
|
|
50
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
51
|
+
if (attempt === maxRetries) {
|
|
52
|
+
throw new RateLimitExceededError(url, retryAfter);
|
|
53
|
+
}
|
|
54
|
+
await sleep(retryAfter * 1e3 + jitter());
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (response.status >= 500 && response.status < 600 && attempt < maxRetries) {
|
|
58
|
+
await sleep(baseDelayMs * 2 ** attempt + jitter());
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const body = await response.text().catch((bodyErr) => {
|
|
62
|
+
console.warn(
|
|
63
|
+
`[france-data-mcp] failed to read response body for ${url}: ${bodyErr.message}`
|
|
64
|
+
);
|
|
65
|
+
return void 0;
|
|
66
|
+
});
|
|
67
|
+
throw new HttpError(
|
|
68
|
+
`HTTP ${response.status} on ${url}`,
|
|
69
|
+
response.status,
|
|
70
|
+
url,
|
|
71
|
+
body?.slice(0, 500)
|
|
72
|
+
);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err instanceof HttpError) throw err;
|
|
75
|
+
lastError = err;
|
|
76
|
+
if (lastError instanceof SyntaxError) {
|
|
77
|
+
console.error(`[france-data-mcp] invalid JSON response from ${url}: ${lastError.message}`);
|
|
78
|
+
throw lastError;
|
|
79
|
+
}
|
|
80
|
+
if (lastError.name === "AbortError") {
|
|
81
|
+
console.warn(`[france-data-mcp] fetch aborted (caller signal) on ${url}`);
|
|
82
|
+
throw lastError;
|
|
83
|
+
}
|
|
84
|
+
if (signal?.aborted) {
|
|
85
|
+
console.warn(
|
|
86
|
+
`[france-data-mcp] fetch aborted on ${url} (signal already aborted) \u2014 last error: ${lastError.message}`
|
|
87
|
+
);
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
const isFinalAttempt = attempt === maxRetries;
|
|
91
|
+
const log = isFinalAttempt ? console.error : console.warn;
|
|
92
|
+
log(
|
|
93
|
+
`[france-data-mcp] network error on ${url} (attempt ${attempt + 1}/${maxRetries + 1}): ${lastError.message}`
|
|
94
|
+
);
|
|
95
|
+
if (isFinalAttempt) break;
|
|
96
|
+
await sleep(baseDelayMs * 2 ** attempt + jitter());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
console.error(`[france-data-mcp] giving up on ${url} after ${maxRetries + 1} attempts`);
|
|
100
|
+
throw lastError ?? new Error(`Unknown failure fetching ${url}`);
|
|
101
|
+
}
|
|
102
|
+
function parseRetryAfter(header) {
|
|
103
|
+
if (!header) return 5;
|
|
104
|
+
const seconds = Number.parseInt(header, 10);
|
|
105
|
+
if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds, 60);
|
|
106
|
+
const dateMs = Date.parse(header);
|
|
107
|
+
if (Number.isFinite(dateMs)) {
|
|
108
|
+
const deltaSec = Math.ceil((dateMs - Date.now()) / 1e3);
|
|
109
|
+
if (deltaSec > 0) return Math.min(deltaSec, 60);
|
|
110
|
+
}
|
|
111
|
+
return 5;
|
|
112
|
+
}
|
|
113
|
+
function jitter() {
|
|
114
|
+
return Math.floor(Math.random() * 250);
|
|
115
|
+
}
|
|
116
|
+
function sleep(ms) {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/core/lookup-result.ts
|
|
121
|
+
function lookupFound(entity) {
|
|
122
|
+
return { ...entity, found: true, lookupStatus: "found" };
|
|
123
|
+
}
|
|
124
|
+
function lookupNotFound(key, message, status = "not_found") {
|
|
125
|
+
return { found: false, key, lookupStatus: status, message };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/core/numbers.ts
|
|
129
|
+
function clamp(value, min, max) {
|
|
130
|
+
return Math.min(Math.max(value, min), max);
|
|
131
|
+
}
|
|
132
|
+
function metersToKm(meters) {
|
|
133
|
+
if (typeof meters !== "number" || !Number.isFinite(meters)) return null;
|
|
134
|
+
return Math.round(meters / 1e3 * 100) / 100;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/core/object-utils.ts
|
|
138
|
+
function pickDefined(obj) {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const key in obj) {
|
|
141
|
+
const value = obj[key];
|
|
142
|
+
if (value !== void 0 && value !== "") {
|
|
143
|
+
out[key] = value;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/territoire/communes.ts
|
|
150
|
+
var BASE_URL = "https://geo.api.gouv.fr";
|
|
151
|
+
var DEFAULT_FIELDS = [
|
|
152
|
+
"nom",
|
|
153
|
+
"code",
|
|
154
|
+
"codesPostaux",
|
|
155
|
+
"centre",
|
|
156
|
+
"population",
|
|
157
|
+
"codeDepartement",
|
|
158
|
+
"codeRegion",
|
|
159
|
+
"codeEpci"
|
|
160
|
+
].join(",");
|
|
161
|
+
async function searchCommunes(options) {
|
|
162
|
+
const { nom, codePostal, code, limit = 10, boostPopulation = false, signal } = options;
|
|
163
|
+
if (!nom && !codePostal && !code) {
|
|
164
|
+
throw new Error("searchCommunes: au moins un crit\xE8re (nom, codePostal, code) est requis");
|
|
165
|
+
}
|
|
166
|
+
const params = new URLSearchParams();
|
|
167
|
+
if (nom) params.set("nom", nom);
|
|
168
|
+
if (codePostal) params.set("codePostal", codePostal);
|
|
169
|
+
if (code) params.set("code", code);
|
|
170
|
+
params.set("fields", DEFAULT_FIELDS);
|
|
171
|
+
params.set("limit", String(clamp(limit, 1, 30)));
|
|
172
|
+
if (boostPopulation) params.set("boost", "population");
|
|
173
|
+
const url = `${BASE_URL}/communes?${params.toString()}`;
|
|
174
|
+
const data = await fetchJson(url, { signal });
|
|
175
|
+
return data.map(toCommune);
|
|
176
|
+
}
|
|
177
|
+
async function getCommuneByCode(code, signal) {
|
|
178
|
+
const results = await searchCommunes({ code, limit: 1, signal });
|
|
179
|
+
const first = results[0];
|
|
180
|
+
if (!first) {
|
|
181
|
+
return lookupNotFound(
|
|
182
|
+
code,
|
|
183
|
+
`Commune introuvable pour le code INSEE "${code}". Causes possibles : code mal form\xE9 (attendu 5 caract\xE8res), commune fusionn\xE9e (r\xE9f\xE9rentiel COG INSEE bouge au 1er janvier), code de canton/EPCI mal interpr\xE9t\xE9 comme commune. Pour disambiguer : utiliser \`autocomplete_commune\` avec un nom partiel.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return lookupFound(first);
|
|
187
|
+
}
|
|
188
|
+
function toCommune(api) {
|
|
189
|
+
const centre = api.centre?.coordinates ? { lon: api.centre.coordinates[0], lat: api.centre.coordinates[1] } : void 0;
|
|
190
|
+
return {
|
|
191
|
+
code: api.code,
|
|
192
|
+
nom: api.nom,
|
|
193
|
+
codesPostaux: api.codesPostaux ?? [],
|
|
194
|
+
...centre ? { centre } : {},
|
|
195
|
+
...api.population !== void 0 ? { population: api.population } : {},
|
|
196
|
+
...pickDefined({
|
|
197
|
+
codeDepartement: api.codeDepartement,
|
|
198
|
+
codeRegion: api.codeRegion,
|
|
199
|
+
codeEpci: api.codeEpci
|
|
200
|
+
})
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/territoire/geocode.ts
|
|
205
|
+
var BASE_URL2 = "https://data.geopf.fr/geocodage";
|
|
206
|
+
var LOW_SCORE_THRESHOLD = 0.5;
|
|
207
|
+
async function geocode(address, options = {}) {
|
|
208
|
+
const results = await geocodeMany(address, { ...options, limit: 1 });
|
|
209
|
+
const top = results[0];
|
|
210
|
+
if (!top) return null;
|
|
211
|
+
if (top.score < LOW_SCORE_THRESHOLD) {
|
|
212
|
+
console.warn(
|
|
213
|
+
`[france-data-mcp] geocode("${address}"): score ${top.score.toFixed(2)} < ${LOW_SCORE_THRESHOLD} \u2014 r\xE9sultat tr\xE8s incertain (label retourn\xE9: "${top.label}").`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return top;
|
|
217
|
+
}
|
|
218
|
+
async function geocodeMany(address, options = {}) {
|
|
219
|
+
const { codePostal, codeCommune, type, limit = 5, signal } = options;
|
|
220
|
+
const params = new URLSearchParams({ q: address });
|
|
221
|
+
params.set("limit", String(clamp(limit, 1, 20)));
|
|
222
|
+
if (codePostal) params.set("postcode", codePostal);
|
|
223
|
+
if (codeCommune) params.set("citycode", codeCommune);
|
|
224
|
+
if (type) params.set("type", type);
|
|
225
|
+
const url = `${BASE_URL2}/search/?${params.toString()}`;
|
|
226
|
+
const data = await fetchJson(url, { signal });
|
|
227
|
+
return data.features.map(toGeocodeResult);
|
|
228
|
+
}
|
|
229
|
+
async function reverseGeocode(point, signal) {
|
|
230
|
+
const params = new URLSearchParams({
|
|
231
|
+
lon: String(point.lon),
|
|
232
|
+
lat: String(point.lat)
|
|
233
|
+
});
|
|
234
|
+
const url = `${BASE_URL2}/reverse/?${params.toString()}`;
|
|
235
|
+
const data = await fetchJson(url, { signal });
|
|
236
|
+
const feature = data.features[0];
|
|
237
|
+
return feature ? toGeocodeResult(feature) : null;
|
|
238
|
+
}
|
|
239
|
+
function toGeocodeResult(feature) {
|
|
240
|
+
const [lon, lat] = feature.geometry.coordinates;
|
|
241
|
+
return {
|
|
242
|
+
point: { lon, lat },
|
|
243
|
+
label: feature.properties.label,
|
|
244
|
+
score: feature.properties.score,
|
|
245
|
+
type: feature.properties.type,
|
|
246
|
+
...pickDefined({
|
|
247
|
+
codePostal: feature.properties.postcode,
|
|
248
|
+
codeCommune: feature.properties.citycode,
|
|
249
|
+
commune: feature.properties.city
|
|
250
|
+
})
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/territoire/index.ts
|
|
255
|
+
var TERRITOIRE_VERSION = "0.1.0";
|
|
256
|
+
|
|
257
|
+
// src/core/coords.ts
|
|
258
|
+
function parseCoordinates(lon, lat) {
|
|
259
|
+
const lonNum = parseLooseNumber(lon);
|
|
260
|
+
const latNum = parseLooseNumber(lat);
|
|
261
|
+
if (lonNum === void 0 || latNum === void 0) return void 0;
|
|
262
|
+
return { lon: lonNum, lat: latNum };
|
|
263
|
+
}
|
|
264
|
+
function parseLooseNumber(value) {
|
|
265
|
+
if (value == null) return void 0;
|
|
266
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
267
|
+
const normalized = value.replace(",", ".");
|
|
268
|
+
const num = Number.parseFloat(normalized);
|
|
269
|
+
return Number.isFinite(num) ? num : void 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/sante/insee-sirene.ts
|
|
273
|
+
var SIRENE_BASE_URL = "https://api.insee.fr/api-sirene/3.11";
|
|
274
|
+
var INSEE_AUTH_HEADER = "X-INSEE-Api-Key-Integration";
|
|
275
|
+
var FETCH_TIMEOUT_MS = 6e4;
|
|
276
|
+
function getInseeApiKey() {
|
|
277
|
+
const raw = process.env.INSEE_SIRENE_API_KEY;
|
|
278
|
+
if (!raw) return null;
|
|
279
|
+
const cleaned = raw.trim().replace(/^["']|["']$/g, "");
|
|
280
|
+
return cleaned === "" ? null : cleaned;
|
|
281
|
+
}
|
|
282
|
+
async function lookupSirenViaInsee(siren) {
|
|
283
|
+
const apiKey = getInseeApiKey();
|
|
284
|
+
if (!apiKey) return null;
|
|
285
|
+
const url = `${SIRENE_BASE_URL}/siren/${encodeURIComponent(siren)}`;
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
288
|
+
let data;
|
|
289
|
+
try {
|
|
290
|
+
data = await fetchJson(url, {
|
|
291
|
+
headers: { [INSEE_AUTH_HEADER]: apiKey },
|
|
292
|
+
signal: controller.signal
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const httpStatus = err instanceof HttpError ? err.status : null;
|
|
296
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
297
|
+
const logFn = httpStatus === 404 ? console.warn : console.error;
|
|
298
|
+
logFn(
|
|
299
|
+
`[france-data-mcp] INSEE SIRENE lookup terminated for siren=${siren} \u2014 ${httpStatus !== null ? `HTTP ${httpStatus}` : `network/parse error: ${errMsg}`}`
|
|
300
|
+
);
|
|
301
|
+
return null;
|
|
302
|
+
} finally {
|
|
303
|
+
clearTimeout(timeout);
|
|
304
|
+
}
|
|
305
|
+
const ul = data.uniteLegale;
|
|
306
|
+
if (!ul) {
|
|
307
|
+
console.warn(`[france-data-mcp] INSEE SIRENE response missing uniteLegale for siren=${siren}`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const periodes = ul.periodesUniteLegale ?? [];
|
|
311
|
+
let periode = periodes.find((p) => p.dateFin === null || p.dateFin === void 0);
|
|
312
|
+
if (!periode && periodes.length > 0) {
|
|
313
|
+
periode = periodes[0];
|
|
314
|
+
console.warn(
|
|
315
|
+
`[france-data-mcp] INSEE SIRENE siren=${siren} : aucune p\xE9riode ouverte (dateFin=null), fallback sur periodesUniteLegale[0] (potentiellement obsol\xE8te)`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
siren,
|
|
320
|
+
nomComplet: deriveNomComplet(periode, siren),
|
|
321
|
+
finances: [],
|
|
322
|
+
dirigeants: [],
|
|
323
|
+
actif: periode?.etatAdministratifUniteLegale === "A",
|
|
324
|
+
etablissements: [],
|
|
325
|
+
enrichmentStatus: "not_attempted",
|
|
326
|
+
siren_source: "insee_v3",
|
|
327
|
+
...periode?.activitePrincipaleUniteLegale ? { naf: periode.activitePrincipaleUniteLegale } : {},
|
|
328
|
+
...periode?.categorieJuridiqueUniteLegale ? { natureJuridique: periode.categorieJuridiqueUniteLegale } : {}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function deriveNomComplet(periode, siren) {
|
|
332
|
+
if (!periode) return siren;
|
|
333
|
+
const denomination = periode.denominationUniteLegale?.trim();
|
|
334
|
+
if (denomination) return denomination;
|
|
335
|
+
const prenom = (periode.prenomUsuelUniteLegale ?? periode.prenom1UniteLegale)?.trim();
|
|
336
|
+
const nom = periode.nomUniteLegale?.trim();
|
|
337
|
+
if (prenom && nom) return `${prenom} ${nom}`;
|
|
338
|
+
if (nom) return nom;
|
|
339
|
+
return siren;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/sante/dinum.ts
|
|
343
|
+
var BASE_URL3 = "https://recherche-entreprises.api.gouv.fr/search";
|
|
344
|
+
async function searchEntreprises(options) {
|
|
345
|
+
const {
|
|
346
|
+
q,
|
|
347
|
+
naf,
|
|
348
|
+
codePostal,
|
|
349
|
+
departement,
|
|
350
|
+
codeCommune,
|
|
351
|
+
center,
|
|
352
|
+
radiusKm,
|
|
353
|
+
onlyActive = true,
|
|
354
|
+
page = 1,
|
|
355
|
+
perPage = 10,
|
|
356
|
+
signal
|
|
357
|
+
} = options;
|
|
358
|
+
if (!q && !naf && !codePostal && !departement && !codeCommune && !center) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
"searchEntreprises: au moins un crit\xE8re est requis (q, naf, codePostal, departement, codeCommune ou center+radiusKm)"
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (center && (radiusKm === void 0 || radiusKm <= 0)) {
|
|
364
|
+
throw new Error("searchEntreprises: radiusKm > 0 requis quand center est fourni");
|
|
365
|
+
}
|
|
366
|
+
if (center && !q) {
|
|
367
|
+
if (naf) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
"searchEntreprises: l'API DINUM n'accepte pas `naf` + `center+radiusKm` directement. Options : (1) `q='<terme>'` + center+radiusKm (recherche textuelle g\xE9olocalis\xE9e), (2) `naf` + `codePostal`/`departement`/`codeCommune` (filtrage administratif), (3) faire un reverseGeocode du center pour obtenir codeCommune puis filtrer."
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
throw new Error(
|
|
373
|
+
"searchEntreprises: `center+radiusKm` requiert un param\xE8tre `q` (recherche textuelle). L'API DINUM ne supporte pas la recherche g\xE9ographique pure."
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const params = new URLSearchParams();
|
|
377
|
+
if (q) params.set("q", q);
|
|
378
|
+
if (naf) params.set("activite_principale", normalizeNafCode(naf));
|
|
379
|
+
if (codePostal) params.set("code_postal", codePostal);
|
|
380
|
+
if (departement) params.set("departement", departement);
|
|
381
|
+
if (codeCommune) params.set("code_commune", codeCommune);
|
|
382
|
+
if (center && radiusKm !== void 0) {
|
|
383
|
+
params.set("lat", String(center.lat));
|
|
384
|
+
params.set("long", String(center.lon));
|
|
385
|
+
params.set("radius", String(Math.min(radiusKm, 50)));
|
|
386
|
+
}
|
|
387
|
+
if (onlyActive) params.set("etat_administratif", "A");
|
|
388
|
+
params.set("page", String(Math.max(1, page)));
|
|
389
|
+
params.set("per_page", String(clamp(perPage, 1, 25)));
|
|
390
|
+
const url = `${BASE_URL3}?${params.toString()}`;
|
|
391
|
+
const data = await fetchJson(url, { signal });
|
|
392
|
+
return {
|
|
393
|
+
total: data.total_results,
|
|
394
|
+
page: data.page,
|
|
395
|
+
perPage: data.per_page,
|
|
396
|
+
totalPages: data.total_pages,
|
|
397
|
+
entreprises: data.results.map(toEntreprise)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async function getEntrepriseBySiren(siren, signal) {
|
|
401
|
+
if (!/^\d{9}$/.test(siren)) {
|
|
402
|
+
throw new Error(`getEntrepriseBySiren: SIREN invalide "${siren}" (attendu 9 chiffres)`);
|
|
403
|
+
}
|
|
404
|
+
const result = await searchEntreprises({ q: siren, perPage: 5, onlyActive: false, signal });
|
|
405
|
+
const match = result.entreprises.find((e) => e.siren === siren);
|
|
406
|
+
if (!match) {
|
|
407
|
+
if (result.entreprises.length > 0) {
|
|
408
|
+
console.warn(
|
|
409
|
+
`[france-data-mcp] getEntrepriseBySiren(${siren}): l'API a renvoy\xE9 ${result.entreprises.length} r\xE9sultat(s) sans match exact du SIREN.`
|
|
410
|
+
);
|
|
411
|
+
return lookupNotFound(
|
|
412
|
+
siren,
|
|
413
|
+
`L'API DINUM a renvoy\xE9 ${result.entreprises.length} r\xE9sultat(s) full-text mais aucun ne correspond exactement au SIREN ${siren}. Possible r\xE9gression c\xF4t\xE9 API DINUM ou faux positif full-text.`,
|
|
414
|
+
"ambiguous"
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
const inseeMatch = await lookupSirenViaInsee(siren);
|
|
418
|
+
if (inseeMatch) return lookupFound(inseeMatch);
|
|
419
|
+
const inseeSuffix = getInseeApiKey() ? "Fallback SIRENE INSEE V3 a aussi retourn\xE9 null (SIREN absent de SIRENE, cl\xE9 r\xE9voqu\xE9e, ou panne API \u2014 voir logs)." : "Fallback SIRENE INSEE V3 non configur\xE9 (env var INSEE_SIRENE_API_KEY absente).";
|
|
420
|
+
return lookupNotFound(
|
|
421
|
+
siren,
|
|
422
|
+
`SIREN ${siren} non trouv\xE9 via DINUM (statut diffusion partielle probable). ${inseeSuffix}`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const siege = match.etablissements.find((e) => e.siret === match.siretSiege) ?? match.etablissements[0];
|
|
426
|
+
const siegePostalCode = siege?.codePostal;
|
|
427
|
+
const departement = deptFromPostal(siegePostalCode);
|
|
428
|
+
const naf = match.naf;
|
|
429
|
+
const totalSirene = match.nombreEtablissements ?? 0;
|
|
430
|
+
if (totalSirene <= 1) {
|
|
431
|
+
match.enrichmentStatus = "not_attempted";
|
|
432
|
+
return lookupFound(match);
|
|
433
|
+
}
|
|
434
|
+
if (!naf || !departement) {
|
|
435
|
+
match.enrichmentStatus = "not_attempted";
|
|
436
|
+
match.enrichmentWarning = warnSkipped({ naf, siegePostalCode, departement });
|
|
437
|
+
return lookupFound(match);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const more = await searchEntreprises({
|
|
441
|
+
naf,
|
|
442
|
+
departement,
|
|
443
|
+
perPage: 25,
|
|
444
|
+
onlyActive: false,
|
|
445
|
+
signal
|
|
446
|
+
});
|
|
447
|
+
const enriched = more.entreprises.find((e) => e.siren === siren);
|
|
448
|
+
if (enriched) {
|
|
449
|
+
const seen = new Set(match.etablissements.map((e) => e.siret));
|
|
450
|
+
for (const et of enriched.etablissements) {
|
|
451
|
+
if (et.siret && !seen.has(et.siret)) {
|
|
452
|
+
match.etablissements.push(et);
|
|
453
|
+
seen.add(et.siret);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (match.etablissements.length >= totalSirene) {
|
|
458
|
+
match.enrichmentStatus = "success";
|
|
459
|
+
} else {
|
|
460
|
+
match.enrichmentStatus = "partial";
|
|
461
|
+
match.enrichmentWarning = warnPartial({
|
|
462
|
+
found: match.etablissements.length,
|
|
463
|
+
totalSirene,
|
|
464
|
+
naf,
|
|
465
|
+
departement
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
470
|
+
const errType = err instanceof Error ? err.constructor.name : typeof err;
|
|
471
|
+
console.error(
|
|
472
|
+
`[france-data-mcp] getEntrepriseBySiren(${siren}): \xE9chec enrichissement (errType=${errType}, naf=${naf}, departement=${departement}): ${msg}`
|
|
473
|
+
);
|
|
474
|
+
match.enrichmentStatus = "failed";
|
|
475
|
+
match.enrichmentWarning = warnFailed({ errType, msg, totalSirene });
|
|
476
|
+
}
|
|
477
|
+
return lookupFound(match);
|
|
478
|
+
}
|
|
479
|
+
function warnSkipped(opts) {
|
|
480
|
+
return `Enrichissement ignor\xE9 (naf=${opts.naf ?? "absent"}, codePostal=${opts.siegePostalCode ?? "absent"}, departement=${opts.departement ?? "non d\xE9ductible"}).`;
|
|
481
|
+
}
|
|
482
|
+
function warnPartial(opts) {
|
|
483
|
+
return `Enrichissement partiel : ${opts.found}/${opts.totalSirene} \xE9tablissements. Strat\xE9gie API DINUM (naf=${opts.naf} + departement=${opts.departement}) ne couvre pas les sites multi-d\xE9partement ni les \xE9tablissements \xE0 NAF diff\xE9rent du si\xE8ge. Pour exhaustivit\xE9 : utiliser \`entreprises_in_radius\` par zone g\xE9ographique, ou interroger SIRENE directement.`;
|
|
484
|
+
}
|
|
485
|
+
function warnFailed(opts) {
|
|
486
|
+
return `Enrichissement \xE9chou\xE9 (${opts.errType}: ${opts.msg}). nombreEtablissements=${opts.totalSirene} mais seul le si\xE8ge est list\xE9. R\xE9essayer plus tard, ou utiliser \`entreprises_in_radius\` pour cibler g\xE9ographiquement.`;
|
|
487
|
+
}
|
|
488
|
+
function deptFromPostal(codePostal) {
|
|
489
|
+
if (!codePostal || codePostal.length < 2) return void 0;
|
|
490
|
+
if (codePostal.startsWith("97") || codePostal.startsWith("98")) {
|
|
491
|
+
return codePostal.length >= 3 ? codePostal.slice(0, 3) : void 0;
|
|
492
|
+
}
|
|
493
|
+
if (codePostal.startsWith("20") && /^\d{5}$/.test(codePostal)) {
|
|
494
|
+
const n = Number.parseInt(codePostal, 10);
|
|
495
|
+
if (n >= 2e4 && n <= 20190) return "2A";
|
|
496
|
+
if (n >= 20200 && n <= 20620) return "2B";
|
|
497
|
+
return void 0;
|
|
498
|
+
}
|
|
499
|
+
return codePostal.slice(0, 2);
|
|
500
|
+
}
|
|
501
|
+
function normalizeNafCode(naf) {
|
|
502
|
+
if (/^\d{2}\.\d{2}[A-Z]$/.test(naf)) return naf;
|
|
503
|
+
if (/^\d{4}[A-Z]$/.test(naf)) return `${naf.slice(0, 2)}.${naf.slice(2)}`;
|
|
504
|
+
return naf;
|
|
505
|
+
}
|
|
506
|
+
function toEntreprise(api) {
|
|
507
|
+
const finances = [];
|
|
508
|
+
if (api.finances) {
|
|
509
|
+
for (const [year, fin] of Object.entries(api.finances)) {
|
|
510
|
+
const annee = Number.parseInt(year, 10);
|
|
511
|
+
if (Number.isFinite(annee)) {
|
|
512
|
+
const caFiable = !(fin.ca === 0 && fin.resultat_net !== void 0 && fin.resultat_net > 0);
|
|
513
|
+
const f = { annee, caFiable };
|
|
514
|
+
if (fin.ca !== void 0) f.ca = fin.ca;
|
|
515
|
+
if (fin.resultat_net !== void 0) f.resultatNet = fin.resultat_net;
|
|
516
|
+
finances.push(f);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
finances.sort((a, b) => b.annee - a.annee);
|
|
520
|
+
}
|
|
521
|
+
const etablissements = [];
|
|
522
|
+
if (api.siege) etablissements.push(toEtablissement(api.siege));
|
|
523
|
+
if (api.matching_etablissements) {
|
|
524
|
+
for (const m of api.matching_etablissements) {
|
|
525
|
+
if (!api.siege || m.siret !== api.siege.siret) {
|
|
526
|
+
etablissements.push(toEtablissement(m));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const entreprise = {
|
|
531
|
+
siren: api.siren,
|
|
532
|
+
nomComplet: api.nom_complet ?? api.nom_raison_sociale ?? api.siren,
|
|
533
|
+
finances,
|
|
534
|
+
dirigeants: (api.dirigeants ?? []).map(toDirigeant),
|
|
535
|
+
etablissements,
|
|
536
|
+
actif: (api.etat_administratif ?? "A") === "A",
|
|
537
|
+
// Toujours présent côté retour DINUM pour cohérence du contrat caller :
|
|
538
|
+
// distingue explicitement "DINUM a répondu" du fallback "insee_v3".
|
|
539
|
+
siren_source: "dinum",
|
|
540
|
+
...pickDefined({
|
|
541
|
+
siretSiege: api.siege?.siret,
|
|
542
|
+
naf: api.activite_principale,
|
|
543
|
+
nafLibelle: api.libelle_activite_principale,
|
|
544
|
+
trancheEffectif: api.tranche_effectif_salarie,
|
|
545
|
+
natureJuridique: api.nature_juridique
|
|
546
|
+
})
|
|
547
|
+
};
|
|
548
|
+
if (api.nombre_etablissements !== void 0) {
|
|
549
|
+
entreprise.nombreEtablissements = api.nombre_etablissements;
|
|
550
|
+
}
|
|
551
|
+
if (api.nombre_etablissements_ouverts !== void 0) {
|
|
552
|
+
entreprise.nombreEtablissementsOuverts = api.nombre_etablissements_ouverts;
|
|
553
|
+
}
|
|
554
|
+
return entreprise;
|
|
555
|
+
}
|
|
556
|
+
function toDirigeant(api) {
|
|
557
|
+
return pickDefined({
|
|
558
|
+
nom: api.nom,
|
|
559
|
+
prenoms: api.prenoms,
|
|
560
|
+
fonction: api.fonction,
|
|
561
|
+
qualite: api.qualite
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
function toEtablissement(api) {
|
|
565
|
+
const point = parseCoordinates(api.longitude, api.latitude);
|
|
566
|
+
return {
|
|
567
|
+
siret: api.siret ?? "",
|
|
568
|
+
adresse: api.adresse ?? "",
|
|
569
|
+
actif: (api.etat_administratif ?? "A") === "A",
|
|
570
|
+
...pickDefined({
|
|
571
|
+
codePostal: api.code_postal,
|
|
572
|
+
commune: api.libelle_commune,
|
|
573
|
+
naf: api.activite_principale,
|
|
574
|
+
trancheEffectif: api.tranche_effectif_salarie,
|
|
575
|
+
dateCreation: api.date_creation
|
|
576
|
+
}),
|
|
577
|
+
...point ? { point } : {}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
var DEFAULT_CACHE_DIR = join(homedir(), ".cache", "france-data-mcp");
|
|
581
|
+
async function downloadWithCache(url, cacheFileName, options = {}) {
|
|
582
|
+
const {
|
|
583
|
+
cacheDir = DEFAULT_CACHE_DIR,
|
|
584
|
+
ttlMs = 7 * 24 * 60 * 60 * 1e3,
|
|
585
|
+
force = false,
|
|
586
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
587
|
+
signal
|
|
588
|
+
} = options;
|
|
589
|
+
const cachePath = join(cacheDir, cacheFileName);
|
|
590
|
+
if (!force && await isCacheFresh(cachePath, ttlMs)) {
|
|
591
|
+
return cachePath;
|
|
592
|
+
}
|
|
593
|
+
await mkdir(dirname(cachePath), { recursive: true });
|
|
594
|
+
const response = await fetch(url, {
|
|
595
|
+
headers: { "User-Agent": userAgent },
|
|
596
|
+
signal
|
|
597
|
+
});
|
|
598
|
+
if (!response.ok) {
|
|
599
|
+
throw new Error(`Failed to download ${url}: HTTP ${response.status} ${response.statusText}`);
|
|
600
|
+
}
|
|
601
|
+
const tmpPath = `${cachePath}.tmp.${process.pid}`;
|
|
602
|
+
try {
|
|
603
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
604
|
+
await writeFile(tmpPath, buffer);
|
|
605
|
+
await rename(tmpPath, cachePath);
|
|
606
|
+
return cachePath;
|
|
607
|
+
} catch (err) {
|
|
608
|
+
console.error(
|
|
609
|
+
`[france-data-mcp] cache write failed for ${url} \u2192 ${cachePath}: ${err.message}`
|
|
610
|
+
);
|
|
611
|
+
await unlink(tmpPath).catch((unlinkErr) => {
|
|
612
|
+
const code = unlinkErr.code;
|
|
613
|
+
if (code !== "ENOENT") {
|
|
614
|
+
console.warn(
|
|
615
|
+
`[france-data-mcp] failed to clean up temp cache file ${tmpPath} (${code ?? "unknown"}): ${unlinkErr.message}`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
throw err;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function isCacheFresh(filePath, ttlMs) {
|
|
623
|
+
if (!existsSync(filePath)) return false;
|
|
624
|
+
try {
|
|
625
|
+
const stats = await stat(filePath);
|
|
626
|
+
const ageMs = Date.now() - stats.mtimeMs;
|
|
627
|
+
return ageMs < ttlMs;
|
|
628
|
+
} catch (err) {
|
|
629
|
+
const code = err.code;
|
|
630
|
+
if (code === "ENOENT") return false;
|
|
631
|
+
console.error(
|
|
632
|
+
`[france-data-mcp] cache stat failed unexpectedly for ${filePath} (${code ?? "unknown"}): ${err.message}`
|
|
633
|
+
);
|
|
634
|
+
throw err;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/core/csv.ts
|
|
639
|
+
var BOM = "\uFEFF";
|
|
640
|
+
function parseCsvLine(line, options = {}) {
|
|
641
|
+
const delimiter = options.delimiter ?? ";";
|
|
642
|
+
const quote = options.quote ?? '"';
|
|
643
|
+
const fields = [];
|
|
644
|
+
let current = "";
|
|
645
|
+
let inQuotes = false;
|
|
646
|
+
let i = 0;
|
|
647
|
+
while (i < line.length) {
|
|
648
|
+
const char = line[i];
|
|
649
|
+
if (inQuotes) {
|
|
650
|
+
if (char === quote) {
|
|
651
|
+
if (line[i + 1] === quote) {
|
|
652
|
+
current += quote;
|
|
653
|
+
i += 2;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
inQuotes = false;
|
|
657
|
+
i++;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
current += char;
|
|
661
|
+
i++;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (char === quote) {
|
|
665
|
+
inQuotes = true;
|
|
666
|
+
i++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (char === delimiter) {
|
|
670
|
+
fields.push(current);
|
|
671
|
+
current = "";
|
|
672
|
+
i++;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
current += char;
|
|
676
|
+
i++;
|
|
677
|
+
}
|
|
678
|
+
fields.push(current);
|
|
679
|
+
return fields;
|
|
680
|
+
}
|
|
681
|
+
function parseCsv(content, options = {}) {
|
|
682
|
+
const cleaned = content.startsWith(BOM) ? content.slice(BOM.length) : content;
|
|
683
|
+
const lines = cleaned.split(/\r?\n/);
|
|
684
|
+
const result = [];
|
|
685
|
+
if (lines.length === 0) return result;
|
|
686
|
+
const headers = parseCsvLine(lines[0] ?? "", options);
|
|
687
|
+
for (let i = 1; i < lines.length; i++) {
|
|
688
|
+
const line = lines[i];
|
|
689
|
+
if (!line) continue;
|
|
690
|
+
const values = parseCsvLine(line, options);
|
|
691
|
+
const record = {};
|
|
692
|
+
for (let j = 0; j < headers.length; j++) {
|
|
693
|
+
const header = headers[j];
|
|
694
|
+
if (header) record[header] = values[j] ?? "";
|
|
695
|
+
}
|
|
696
|
+
result.push(record);
|
|
697
|
+
}
|
|
698
|
+
return result;
|
|
699
|
+
}
|
|
700
|
+
async function* streamCsvLines(source, options = {}) {
|
|
701
|
+
let buffer = "";
|
|
702
|
+
let headers = null;
|
|
703
|
+
let firstChunk = true;
|
|
704
|
+
for await (const chunk of source) {
|
|
705
|
+
const data = firstChunk && chunk.startsWith(BOM) ? chunk.slice(BOM.length) : chunk;
|
|
706
|
+
firstChunk = false;
|
|
707
|
+
buffer += data;
|
|
708
|
+
const lines = buffer.split(/\r?\n/);
|
|
709
|
+
buffer = lines.pop() ?? "";
|
|
710
|
+
for (const line of lines) {
|
|
711
|
+
if (!line) continue;
|
|
712
|
+
if (!headers) {
|
|
713
|
+
headers = parseCsvLine(line, options);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
yield rowToObject(headers, parseCsvLine(line, options));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (buffer.trim().length > 0 && headers) {
|
|
720
|
+
yield rowToObject(headers, parseCsvLine(buffer, options));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function rowToObject(headers, values) {
|
|
724
|
+
const obj = {};
|
|
725
|
+
for (let i = 0; i < headers.length; i++) {
|
|
726
|
+
const header = headers[i];
|
|
727
|
+
if (header) obj[header] = values[i] ?? "";
|
|
728
|
+
}
|
|
729
|
+
return obj;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/sante/finess-categories.ts
|
|
733
|
+
var FINESS_CATEGORIES = {
|
|
734
|
+
// ─── SANITAIRE — court séjour (MCO + adjacents) ───────────────────────
|
|
735
|
+
"101": "Centre Hospitalier R\xE9gional (C.H.R.)",
|
|
736
|
+
"106": "Centre hospitalier",
|
|
737
|
+
"108": "Centre Hospitalier Universitaire (C.H.U.)",
|
|
738
|
+
"114": "H\xF4pital des arm\xE9es",
|
|
739
|
+
"115": "Etablissement de Soins du Service de Sant\xE9 des Arm\xE9es",
|
|
740
|
+
"128": "Etablissement de Soins Chirurgicaux",
|
|
741
|
+
"129": "Etablissement de Soins M\xE9dicaux",
|
|
742
|
+
"131": "Centre de Lutte Contre le Cancer (C.L.C.C.)",
|
|
743
|
+
"355": "Centre Hospitalier (C.H.)",
|
|
744
|
+
"365": "Etablissement de Soins Pluridisciplinaire",
|
|
745
|
+
// ─── SANITAIRE — SSR / SLD / HAD / dialyse ────────────────────────────
|
|
746
|
+
"109": "Etablissement de sant\xE9 priv\xE9 autoris\xE9 en SSR",
|
|
747
|
+
"362": "Etablissement de Soins Longue Dur\xE9e (USLD)",
|
|
748
|
+
"127": "Hospitalisation \xE0 Domicile (HAD)",
|
|
749
|
+
"141": "Centre de dialyse",
|
|
750
|
+
"146": "Structure d'Alternative \xE0 la dialyse en centre",
|
|
751
|
+
// ─── SANITAIRE — psychiatrie ──────────────────────────────────────────
|
|
752
|
+
"292": "Centre Hospitalier Sp\xE9cialis\xE9 lutte Maladies Mentales",
|
|
753
|
+
"156": "Centre M\xE9dico-Psychologique (C.M.P.)",
|
|
754
|
+
"161": "Maison de Sant\xE9 pour Maladies Mentales",
|
|
755
|
+
"425": "Centre d'Accueil Th\xE9rapeutique \xE0 temps partiel (C.A.T.T.P.)",
|
|
756
|
+
"430": "Centre Postcure Malades Mentaux",
|
|
757
|
+
// ─── AMBULATOIRE / soins de ville ─────────────────────────────────────
|
|
758
|
+
"124": "Centre de Sant\xE9",
|
|
759
|
+
"603": "Maison de sant\xE9 (L.6223-3)",
|
|
760
|
+
"604": "Communaut\xE9s professionnelles territoriales de sant\xE9 (CPTS)",
|
|
761
|
+
// ─── PHARMACIE / BIO / IMAGERIE ───────────────────────────────────────
|
|
762
|
+
"611": "Laboratoire de Biologie M\xE9dicale",
|
|
763
|
+
"619": "Cabinet d'imagerie m\xE9dicale",
|
|
764
|
+
"620": "Pharmacie d'Officine",
|
|
765
|
+
"627": "Propharmacie",
|
|
766
|
+
// ─── PERSONNES ÂGÉES — hébergement ────────────────────────────────────
|
|
767
|
+
"500": "Etablissement d'h\xE9bergement pour personnes \xE2g\xE9es d\xE9pendantes (EHPAD)",
|
|
768
|
+
"501": "EHPA percevant des cr\xE9dits d'assurance maladie",
|
|
769
|
+
"502": "EHPA ne percevant pas des cr\xE9dits d'assurance maladie",
|
|
770
|
+
"202": "R\xE9sidences autonomie",
|
|
771
|
+
// ─── PERSONNES ÂGÉES — accompagnement ─────────────────────────────────
|
|
772
|
+
"207": "Centre de Jour pour Personnes Ag\xE9es",
|
|
773
|
+
"463": "Centres Locaux Information Coordination P.A. (C.L.I.C.)",
|
|
774
|
+
// ─── DOMICILE (médico-social + soins) ─────────────────────────────────
|
|
775
|
+
"354": "Service de Soins Infirmiers \xE0 Domicile (S.S.I.A.D.)",
|
|
776
|
+
"460": "Service d'Aide et d'Accompagnement \xE0 Domicile (S.A.A.D.)",
|
|
777
|
+
"209": "Service Polyvalent Aide et Soins \xE0 Domicile (S.P.A.S.A.D.)",
|
|
778
|
+
// ─── HANDICAP ENFANTS ─────────────────────────────────────────────────
|
|
779
|
+
"182": "Service d'\xC9ducation Sp\xE9ciale et de Soins \xE0 Domicile (SESSAD)",
|
|
780
|
+
"183": "Institut M\xE9dico-\xC9ducatif (I.M.E.)",
|
|
781
|
+
"186": "Institut Th\xE9rapeutique \xC9ducatif et P\xE9dagogique (I.T.E.P.)",
|
|
782
|
+
"188": "Etablissement pour Enfants ou Adolescents Polyhandicap\xE9s",
|
|
783
|
+
"189": "Centre M\xE9dico-Psycho-P\xE9dagogique (C.M.P.P.)",
|
|
784
|
+
"190": "Centre Action M\xE9dico-Sociale Pr\xE9coce (C.A.M.S.P.)",
|
|
785
|
+
"192": "Institut d'\xE9ducation motrice",
|
|
786
|
+
"194": "Institut pour D\xE9ficients Visuels",
|
|
787
|
+
"195": "Institut pour D\xE9ficients Auditifs",
|
|
788
|
+
"196": "Institut d'Education Sensorielle Sourd/Aveugle",
|
|
789
|
+
// ─── HANDICAP ADULTES ─────────────────────────────────────────────────
|
|
790
|
+
"246": "Etablissement et Service d'Aide par le Travail (E.S.A.T.)",
|
|
791
|
+
"247": "Entreprise adapt\xE9e",
|
|
792
|
+
"252": "Foyer H\xE9bergement Adultes Handicap\xE9s",
|
|
793
|
+
"255": "Maison d'Accueil Sp\xE9cialis\xE9e (M.A.S.)",
|
|
794
|
+
"382": "Foyer de Vie pour Adultes Handicap\xE9s",
|
|
795
|
+
"437": "Foyer d'Accueil M\xE9dicalis\xE9 pour Adultes Handicap\xE9s (F.A.M.)",
|
|
796
|
+
"445": "Service d'accompagnement m\xE9dico-social adultes handicap\xE9s (SAMSAH)",
|
|
797
|
+
"446": "Service d'Accompagnement \xE0 la Vie Sociale (S.A.V.S.)",
|
|
798
|
+
"448": "Etab. Acc. M\xE9dicalis\xE9 en tout ou partie personnes handicap\xE9es",
|
|
799
|
+
"449": "Etab. Accueil Non M\xE9dicalis\xE9 pour personnes handicap\xE9es",
|
|
800
|
+
"600": "Foyer d'h\xE9bergement pour adultes handicap\xE9s",
|
|
801
|
+
// ─── ADDICTOLOGIE / accompagnement ────────────────────────────────────
|
|
802
|
+
"165": "Appartement de Coordination Th\xE9rapeutique (A.C.T.)",
|
|
803
|
+
"178": "Centre Accueil/Accomp. R\xE9duc. Risq. Usag. Drogues (C.A.A.R.U.D.)",
|
|
804
|
+
"180": "Lits Halte Soins Sant\xE9 (L.H.S.S.)",
|
|
805
|
+
"197": "Centre soins accompagnement pr\xE9vention addictologie (C.S.A.P.A.)",
|
|
806
|
+
"412": "Appartement Th\xE9rapeutique",
|
|
807
|
+
// ─── ENFANCE / PROTECTION ─────────────────────────────────────────────
|
|
808
|
+
"175": "Foyer de l'Enfance",
|
|
809
|
+
"177": "Maison d'Enfants \xE0 Caract\xE8re Social (MECS)",
|
|
810
|
+
"295": "Services AEMO et AED",
|
|
811
|
+
"236": "Centre Placement Familial Socio-Educatif (C.P.F.S.E.)",
|
|
812
|
+
"238": "Centre d'Accueil Familial Sp\xE9cialis\xE9",
|
|
813
|
+
"241": "Foyer d'Action Educative (F.A.E.)",
|
|
814
|
+
"440": "Service Investigation Orientation Educative (S.I.O.E.)",
|
|
815
|
+
"441": "Centre d'Action Educative (C.A.E.)",
|
|
816
|
+
"378": "Etablissement Exp\xE9rimental Enfance Prot\xE9g\xE9e",
|
|
817
|
+
// ─── PMI / PETITE ENFANCE / SANTÉ SCOLAIRE ────────────────────────────
|
|
818
|
+
"223": "Protection Maternelle et Infantile (P.M.I.)",
|
|
819
|
+
"228": "Centre Planification ou Education Familiale",
|
|
820
|
+
"230": "Etablissement Consultation Protection Infantile",
|
|
821
|
+
"268": "Centre M\xE9dico-Scolaire",
|
|
822
|
+
// ─── HÉBERGEMENT SOCIAL ───────────────────────────────────────────────
|
|
823
|
+
"214": "Centre H\xE9bergement & R\xE9insertion Sociale (C.H.R.S.)",
|
|
824
|
+
"219": "Autre Centre d'Accueil",
|
|
825
|
+
"256": "Foyer Travailleurs Migrants non transform\xE9 en R\xE9sidence Sociale",
|
|
826
|
+
"257": "Foyer de Jeunes Travailleurs (r\xE9sidence sociale ou non)",
|
|
827
|
+
"258": "Maisons Relais - Pensions de Famille",
|
|
828
|
+
"259": "Autre R\xE9sidence Sociale (hors Maison Relais)",
|
|
829
|
+
"443": "Centre Accueil Demandeurs Asile (C.A.D.A.)",
|
|
830
|
+
"442": "Centre Provisoire H\xE9bergement (C.P.H.)",
|
|
831
|
+
"462": "Lieux de vie",
|
|
832
|
+
"166": "Etablissement d'Accueil M\xE8re-Enfant",
|
|
833
|
+
// ─── PRÉVENTION / SANTÉ PUBLIQUE ──────────────────────────────────────
|
|
834
|
+
"132": "Etablissement de Transfusion Sanguine",
|
|
835
|
+
"142": "Dispensaire Antituberculeux",
|
|
836
|
+
"143": "Centre de Vaccination BCG",
|
|
837
|
+
"266": "Dispensaire Antiv\xE9n\xE9rien",
|
|
838
|
+
"347": "Centre d'Examens de Sant\xE9",
|
|
839
|
+
"636": "Centre de soins et de pr\xE9vention",
|
|
840
|
+
// ─── GROUPEMENTS ──────────────────────────────────────────────────────
|
|
841
|
+
"696": "Groupement de coop\xE9ration sanitaire de moyens",
|
|
842
|
+
"697": "Groupement de coop\xE9ration sanitaire \u2014 Etablissement de sant\xE9",
|
|
843
|
+
// ─── HORS TAXONOMIE (voir DELIBERATELY_AUTRE pour la justification) ───
|
|
844
|
+
"126": "Etablissement Thermal",
|
|
845
|
+
"632": "Structure Dispensatrice \xE0 domicile d'Oxyg\xE8ne \xE0 usage m\xE9dical",
|
|
846
|
+
"698": "Autre Etablissement Loi Hospitali\xE8re"
|
|
847
|
+
};
|
|
848
|
+
function libelleCategorieFiness(code) {
|
|
849
|
+
return FINESS_CATEGORIES[code];
|
|
850
|
+
}
|
|
851
|
+
var FINESS_FAMILY_CODES = {
|
|
852
|
+
// Sanitaire — court séjour
|
|
853
|
+
mco: ["101", "106", "108", "114", "115", "128", "129", "131", "355", "365"],
|
|
854
|
+
ssr: ["109"],
|
|
855
|
+
sld: ["362"],
|
|
856
|
+
had: ["127"],
|
|
857
|
+
psychiatrie: ["292", "156", "161", "425", "430"],
|
|
858
|
+
dialyse: ["141", "146"],
|
|
859
|
+
ambulatoire: ["124"],
|
|
860
|
+
// Bio / pharma / imagerie
|
|
861
|
+
labo: ["611"],
|
|
862
|
+
imagerie: ["619"],
|
|
863
|
+
pharmacie: ["620", "627"],
|
|
864
|
+
// Pluri-pro
|
|
865
|
+
msp_cpts: ["603", "604"],
|
|
866
|
+
// Personnes âgées
|
|
867
|
+
ehpad: ["500", "501", "502"],
|
|
868
|
+
residence_autonomie: ["202"],
|
|
869
|
+
senior_accompagnement: ["207", "463"],
|
|
870
|
+
// Domicile
|
|
871
|
+
ssiad: ["354"],
|
|
872
|
+
aide_domicile: ["460", "209"],
|
|
873
|
+
// Handicap
|
|
874
|
+
handicap_enfants: ["182", "183", "186", "188", "189", "190", "192", "194", "195", "196"],
|
|
875
|
+
handicap_adultes: ["246", "247", "252", "255", "382", "437", "445", "446", "448", "449", "600"],
|
|
876
|
+
// Addictologie + précarité sanitaire
|
|
877
|
+
addictologie: ["165", "178", "180", "197", "412"],
|
|
878
|
+
// Enfance / protection
|
|
879
|
+
enfance_protection: ["175", "177", "236", "238", "241", "295", "378", "440", "441"],
|
|
880
|
+
// PMI / petite enfance
|
|
881
|
+
pmi: ["223", "228", "230", "268"],
|
|
882
|
+
// Hébergement social
|
|
883
|
+
hebergement_social: ["166", "214", "219", "256", "257", "258", "259", "442", "443", "462"],
|
|
884
|
+
// Prévention / santé publique
|
|
885
|
+
prevention_sante: ["132", "142", "143", "266", "347", "636"],
|
|
886
|
+
// Groupements
|
|
887
|
+
groupement: ["696", "697"]
|
|
888
|
+
};
|
|
889
|
+
var FAMILY_BY_CODE = new Map(
|
|
890
|
+
Object.keys(FINESS_FAMILY_CODES).flatMap(
|
|
891
|
+
(fam) => FINESS_FAMILY_CODES[fam].map((code) => [code, fam])
|
|
892
|
+
)
|
|
893
|
+
);
|
|
894
|
+
function finessFamille(code) {
|
|
895
|
+
const trimmed = code?.trim();
|
|
896
|
+
if (!trimmed) return "autre";
|
|
897
|
+
return FAMILY_BY_CODE.get(trimmed) ?? "autre";
|
|
898
|
+
}
|
|
899
|
+
var FINESS_HOPITAUX = [
|
|
900
|
+
...FINESS_FAMILY_CODES.mco,
|
|
901
|
+
...FINESS_FAMILY_CODES.ssr,
|
|
902
|
+
...FINESS_FAMILY_CODES.sld,
|
|
903
|
+
...FINESS_FAMILY_CODES.had,
|
|
904
|
+
...FINESS_FAMILY_CODES.psychiatrie
|
|
905
|
+
];
|
|
906
|
+
var FINESS_LABOS = FINESS_FAMILY_CODES.labo;
|
|
907
|
+
var FINESS_PHARMACIES = FINESS_FAMILY_CODES.pharmacie;
|
|
908
|
+
var FINESS_EHPAD = FINESS_FAMILY_CODES.ehpad;
|
|
909
|
+
var FINESS_MSP_CPTS = FINESS_FAMILY_CODES.msp_cpts;
|
|
910
|
+
|
|
911
|
+
// src/sante/finess.ts
|
|
912
|
+
var FINESS_CSV_URL = "https://www.data.gouv.fr/api/1/datasets/r/3dc9b1d5-0157-440d-a7b5-c894fcfdfd45";
|
|
913
|
+
var FINESS_CACHE_FILE = "finess-etablissements.csv";
|
|
914
|
+
async function loadFiness(options = {}) {
|
|
915
|
+
const csvPath = options.csvPath ?? await downloadWithCache(FINESS_CSV_URL, FINESS_CACHE_FILE, options);
|
|
916
|
+
const content = await readFile(csvPath, "utf-8");
|
|
917
|
+
const rows = parseCsv(content, { delimiter: ";" });
|
|
918
|
+
const ets = [];
|
|
919
|
+
for (const row of rows) {
|
|
920
|
+
const e = toEtablissementFiness(row);
|
|
921
|
+
if (e !== null) ets.push(e);
|
|
922
|
+
}
|
|
923
|
+
const total = rows.length;
|
|
924
|
+
if (total > 100) {
|
|
925
|
+
const dropRate = (total - ets.length) / total;
|
|
926
|
+
if (dropRate > 0.05) {
|
|
927
|
+
console.error(
|
|
928
|
+
`[france-data-mcp] FINESS: ${total - ets.length}/${total} lignes invalides (${(dropRate * 100).toFixed(1)}%). Sch\xE9ma CSV probablement chang\xE9. Colonnes attendues: nofinesset, rs, categetab, cpostal, commune. Migration ANS pr\xE9vue \xE9t\xE9 2026 \u2014 v\xE9rifier github.com/ansforge/finess et https://www.data.gouv.fr/datasets/finess-extraction-du-fichier-des-etablissements-sanitaires-et-sociaux/`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return ets;
|
|
933
|
+
}
|
|
934
|
+
function searchEtablissementsFiness(index, options) {
|
|
935
|
+
const { categories, codePostal, departement, codeCommune, center, radiusKm, limit } = options;
|
|
936
|
+
if (center && (radiusKm === void 0 || radiusKm <= 0)) {
|
|
937
|
+
throw new Error("searchEtablissementsFiness: radiusKm > 0 requis quand center est fourni");
|
|
938
|
+
}
|
|
939
|
+
const radiusMeters = center && radiusKm !== void 0 ? radiusKm * 1e3 : null;
|
|
940
|
+
const categoriesSet = categories ? new Set(categories) : null;
|
|
941
|
+
const matches = [];
|
|
942
|
+
for (const e of index) {
|
|
943
|
+
if (categoriesSet && (!e.categorieCode || !categoriesSet.has(e.categorieCode))) continue;
|
|
944
|
+
if (codePostal && e.codePostal !== codePostal) continue;
|
|
945
|
+
if (departement && e.departement !== departement) continue;
|
|
946
|
+
if (codeCommune && e.codeCommune !== codeCommune) continue;
|
|
947
|
+
if (center && radiusMeters !== null) {
|
|
948
|
+
if (!e.point) continue;
|
|
949
|
+
if (haversineDistance(center, e.point) > radiusMeters) continue;
|
|
950
|
+
}
|
|
951
|
+
matches.push(e);
|
|
952
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
953
|
+
}
|
|
954
|
+
return matches;
|
|
955
|
+
}
|
|
956
|
+
function haversineDistance(a, b) {
|
|
957
|
+
const R = 6371e3;
|
|
958
|
+
const lat1 = toRad(a.lat);
|
|
959
|
+
const lat2 = toRad(b.lat);
|
|
960
|
+
const deltaLat = toRad(b.lat - a.lat);
|
|
961
|
+
const deltaLon = toRad(b.lon - a.lon);
|
|
962
|
+
const h = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
|
963
|
+
return 2 * R * Math.asin(Math.sqrt(h));
|
|
964
|
+
}
|
|
965
|
+
function toRad(deg) {
|
|
966
|
+
return deg * Math.PI / 180;
|
|
967
|
+
}
|
|
968
|
+
function toEtablissementFiness(row) {
|
|
969
|
+
const finessEt = row.nofinesset ?? row["FINESS ET"] ?? row.finesset;
|
|
970
|
+
if (!finessEt) return null;
|
|
971
|
+
const categorieCode = row.categetab ?? row.categagretab ?? row.libcategetab;
|
|
972
|
+
const categorieLibelle = categorieCode ? libelleCategorieFiness(categorieCode) ?? row.libcategetab : void 0;
|
|
973
|
+
const adresseParts = [row.numvoie, row.typvoie, row.voie, row.compvoie].filter(Boolean);
|
|
974
|
+
const adresse = adresseParts.length > 0 ? adresseParts.join(" ").trim() : row.adresse;
|
|
975
|
+
const point = parseCoordinates(row.coordxet ?? row.longitude, row.coordyet ?? row.latitude);
|
|
976
|
+
return {
|
|
977
|
+
finessEt,
|
|
978
|
+
raisonSociale: row.rs ?? row.raisonsociale ?? row["Raison sociale"] ?? row.rslongue ?? "",
|
|
979
|
+
...pickDefined({
|
|
980
|
+
finessEj: row.nofinessej ?? row["FINESS EJ"] ?? row.finessej,
|
|
981
|
+
categorieCode,
|
|
982
|
+
categorieLibelle,
|
|
983
|
+
adresse,
|
|
984
|
+
codePostal: row.cpostal ?? row.codepostal,
|
|
985
|
+
commune: row.commune ?? row.libcommune,
|
|
986
|
+
codeCommune: row.codecommune ?? row.codinsee,
|
|
987
|
+
departement: row.departement ?? row.codedepartement,
|
|
988
|
+
telephone: row.telephone ?? row.tel,
|
|
989
|
+
siren: row.siren
|
|
990
|
+
}),
|
|
991
|
+
...point ? { point } : {}
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
var ANNUAIRE_AMELI_CSV_URL = "https://www.data.gouv.fr/api/1/datasets/r/3a700a1c-3079-4f7f-9bd7-83611e3f5e35";
|
|
995
|
+
var ANNUAIRE_AMELI_CACHE_FILE = "annuaire-sante-ameli-ps.csv";
|
|
996
|
+
async function ensureAnnuaireAmeli(options = {}) {
|
|
997
|
+
if (options.csvPath) return options.csvPath;
|
|
998
|
+
return downloadWithCache(ANNUAIRE_AMELI_CSV_URL, ANNUAIRE_AMELI_CACHE_FILE, options);
|
|
999
|
+
}
|
|
1000
|
+
async function* streamProfessionnels(options = {}) {
|
|
1001
|
+
const csvPath = await ensureAnnuaireAmeli(options);
|
|
1002
|
+
const fileStream = createReadStream(csvPath, { encoding: "utf-8" });
|
|
1003
|
+
const {
|
|
1004
|
+
codePostal,
|
|
1005
|
+
codePostalPrefix,
|
|
1006
|
+
commune,
|
|
1007
|
+
specialite,
|
|
1008
|
+
specialiteCode,
|
|
1009
|
+
typePs,
|
|
1010
|
+
secteurConventionnel,
|
|
1011
|
+
limit
|
|
1012
|
+
} = options;
|
|
1013
|
+
const specialiteLower = specialite?.toLowerCase();
|
|
1014
|
+
const communeLower = commune?.toLowerCase();
|
|
1015
|
+
const typePsLower = typePs?.toLowerCase();
|
|
1016
|
+
let yielded = 0;
|
|
1017
|
+
let parsed = 0;
|
|
1018
|
+
let skipped = 0;
|
|
1019
|
+
try {
|
|
1020
|
+
const stringStream = nodeReadableToAsyncIterable(fileStream);
|
|
1021
|
+
for await (const row of streamCsvLines(stringStream, { delimiter: ";" })) {
|
|
1022
|
+
parsed++;
|
|
1023
|
+
const ps = toProfessionnelSante(row);
|
|
1024
|
+
if (!ps) {
|
|
1025
|
+
skipped++;
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (codePostal && ps.codePostal !== codePostal) continue;
|
|
1029
|
+
if (codePostalPrefix && (!ps.codePostal || !ps.codePostal.startsWith(codePostalPrefix)))
|
|
1030
|
+
continue;
|
|
1031
|
+
if (communeLower && (!ps.commune || !ps.commune.toLowerCase().includes(communeLower)))
|
|
1032
|
+
continue;
|
|
1033
|
+
if (specialiteLower && (!ps.specialiteLibelle || !ps.specialiteLibelle.toLowerCase().includes(specialiteLower)))
|
|
1034
|
+
continue;
|
|
1035
|
+
if (specialiteCode && ps.specialiteCode !== specialiteCode) continue;
|
|
1036
|
+
if (typePsLower && (!ps.typePsLibelle || !ps.typePsLibelle.toLowerCase().includes(typePsLower)))
|
|
1037
|
+
continue;
|
|
1038
|
+
if (secteurConventionnel && ps.secteurConventionnel !== secteurConventionnel) continue;
|
|
1039
|
+
yield ps;
|
|
1040
|
+
yielded++;
|
|
1041
|
+
if (limit !== void 0 && yielded >= limit) return;
|
|
1042
|
+
}
|
|
1043
|
+
} finally {
|
|
1044
|
+
fileStream.destroy();
|
|
1045
|
+
if (parsed > 100 && skipped > parsed * 0.1) {
|
|
1046
|
+
console.warn(
|
|
1047
|
+
`[france-data-mcp] annuaire-ameli: ${skipped}/${parsed} lignes invalides (${(skipped / parsed * 100).toFixed(1)}%). Sch\xE9ma CSV peut-\xEAtre chang\xE9. Colonnes attendues: ps_activite_nom, ps_activite_prenom, specialite_libelle, coordonnees_code_postal, coordonnees_ville. V\xE9rifier https://www.data.gouv.fr/datasets/annuaire-sante-ameli/`
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
async function loadProfessionnels(options = {}) {
|
|
1053
|
+
const result = [];
|
|
1054
|
+
for await (const ps of streamProfessionnels(options)) {
|
|
1055
|
+
result.push(ps);
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
1058
|
+
}
|
|
1059
|
+
async function* nodeReadableToAsyncIterable(readable) {
|
|
1060
|
+
try {
|
|
1061
|
+
for await (const chunk of readable) {
|
|
1062
|
+
yield typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
1063
|
+
}
|
|
1064
|
+
} finally {
|
|
1065
|
+
if ("destroy" in readable && typeof readable.destroy === "function") {
|
|
1066
|
+
readable.destroy();
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function toProfessionnelSante(row) {
|
|
1071
|
+
const nom = row.ps_activite_nom ?? "";
|
|
1072
|
+
const prenom = row.ps_activite_prenom ?? "";
|
|
1073
|
+
if (!nom && !prenom) return null;
|
|
1074
|
+
return {
|
|
1075
|
+
nom,
|
|
1076
|
+
prenom,
|
|
1077
|
+
...pickDefined({
|
|
1078
|
+
civilite: row.ps_activite_civilite,
|
|
1079
|
+
raisonSociale: row.ps_activite_raison_sociale,
|
|
1080
|
+
specialiteCode: row.specialite_code,
|
|
1081
|
+
specialiteLibelle: row.specialite_libelle,
|
|
1082
|
+
typePsCode: row.type_ps_code,
|
|
1083
|
+
typePsLibelle: row.type_ps_libelle,
|
|
1084
|
+
adresse: row.coordonnees_voie,
|
|
1085
|
+
complementAdresse: row.coordonnees_complement || row.coordonnees_lieu_dit,
|
|
1086
|
+
codePostal: row.coordonnees_code_postal,
|
|
1087
|
+
commune: row.coordonnees_ville,
|
|
1088
|
+
telephone: row.coordonnees_num_tel,
|
|
1089
|
+
secteurConventionnel: row.secteur_conventionnel_code,
|
|
1090
|
+
secteurConventionnelLibelle: row.secteur_conventionnel_libelle,
|
|
1091
|
+
natureExercice: row.nature_exercice_libelle || row.nature_exercice_code
|
|
1092
|
+
})
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/sante/naf-codes.ts
|
|
1097
|
+
var NAF_SANTE = {
|
|
1098
|
+
// Hôpitaux et cliniques
|
|
1099
|
+
"8610Z": "Activit\xE9s hospitali\xE8res",
|
|
1100
|
+
// Pratique médicale et dentaire
|
|
1101
|
+
"8621Z": "Activit\xE9s de m\xE9decine g\xE9n\xE9rale",
|
|
1102
|
+
"8622A": "Activit\xE9s de radiodiagnostic et de radioth\xE9rapie",
|
|
1103
|
+
"8622B": "Activit\xE9s chirurgicales",
|
|
1104
|
+
"8622C": "Autres activit\xE9s des m\xE9decins sp\xE9cialistes",
|
|
1105
|
+
"8623Z": "Pratique dentaire",
|
|
1106
|
+
// Autres activités de santé
|
|
1107
|
+
"8690A": "Ambulances",
|
|
1108
|
+
"8690B": "Laboratoires d'analyses m\xE9dicales",
|
|
1109
|
+
"8690C": "Centres de collecte et banques d'organes",
|
|
1110
|
+
"8690D": "Activit\xE9s des infirmiers et des sages-femmes",
|
|
1111
|
+
"8690E": "Activit\xE9s des professionnels de la r\xE9\xE9ducation, de l'appareillage et des p\xE9dicures-podologues",
|
|
1112
|
+
"8690F": "Activit\xE9s de sant\xE9 humaine non class\xE9es ailleurs",
|
|
1113
|
+
// Hébergement médico-social
|
|
1114
|
+
"8710A": "H\xE9bergement m\xE9dicalis\xE9 pour personnes \xE2g\xE9es",
|
|
1115
|
+
"8710B": "H\xE9bergement m\xE9dicalis\xE9 pour enfants handicap\xE9s",
|
|
1116
|
+
"8710C": "H\xE9bergement m\xE9dicalis\xE9 pour adultes handicap\xE9s et autre h\xE9bergement m\xE9dicalis\xE9",
|
|
1117
|
+
"8720A": "H\xE9bergement social pour handicap\xE9s mentaux et malades mentaux",
|
|
1118
|
+
"8720B": "H\xE9bergement social pour toxicomanes",
|
|
1119
|
+
"8730A": "H\xE9bergement social pour personnes \xE2g\xE9es",
|
|
1120
|
+
"8730B": "H\xE9bergement social pour handicap\xE9s physiques",
|
|
1121
|
+
// Action sociale sans hébergement
|
|
1122
|
+
"8810A": "Aide \xE0 domicile",
|
|
1123
|
+
"8810B": "Accueil ou accompagnement sans h\xE9bergement d'adultes handicap\xE9s ou de personnes \xE2g\xE9es",
|
|
1124
|
+
"8810C": "Aide par le travail",
|
|
1125
|
+
"8891A": "Accueil de jeunes enfants",
|
|
1126
|
+
"8891B": "Accueil ou accompagnement sans h\xE9bergement d'enfants handicap\xE9s",
|
|
1127
|
+
"8899A": "Autre accueil ou accompagnement sans h\xE9bergement d'enfants et d'adolescents",
|
|
1128
|
+
"8899B": "Action sociale sans h\xE9bergement n.c.a.",
|
|
1129
|
+
// Pharmacies et commerce de matériel médical
|
|
1130
|
+
"4773Z": "Commerce de d\xE9tail de produits pharmaceutiques en magasin sp\xE9cialis\xE9",
|
|
1131
|
+
"4774Z": "Commerce de d\xE9tail d'articles m\xE9dicaux et orthop\xE9diques en magasin sp\xE9cialis\xE9"
|
|
1132
|
+
};
|
|
1133
|
+
var NAF_LABOS = ["8690B"];
|
|
1134
|
+
var NAF_PHARMACIES = ["4773Z"];
|
|
1135
|
+
var NAF_EHPAD = ["8710A", "8730A"];
|
|
1136
|
+
var NAF_MEDECINE_VILLE = [
|
|
1137
|
+
"8621Z",
|
|
1138
|
+
"8622A",
|
|
1139
|
+
"8622B",
|
|
1140
|
+
"8622C"
|
|
1141
|
+
];
|
|
1142
|
+
function libelleNaf(code) {
|
|
1143
|
+
return NAF_SANTE[code];
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/core/query-metadata.ts
|
|
1147
|
+
var SOURCE_NOTE = {
|
|
1148
|
+
centroide_commune_ameli: "Coordonn\xE9es Ameli = centro\xEFde commune (~3 km moyenne). Adapt\xE9 \xE0 l'analyse de densit\xE9 m\xE9dicale, pas au g\xE9ocodage adresse.",
|
|
1149
|
+
lambert93_natif_finess: "FINESS DREES (sync bimestrielle) \u2014 r\xE9f\xE9rentiel peut avoir 1-2 mois de retard sur le terrain pour les structures \xE9mergentes (CPTS r\xE9centes, MSP en agr\xE9ment). Cross-check ARS / Service Public si n\xE9cessaire.",
|
|
1150
|
+
centroide_commune_ans: "Coordonn\xE9es RPPS/ANS = centro\xEFde commune (~3 km moyenne). Source : Annuaire Sant\xE9 ANS \u2014 Licence Ouverte v2.0. Pour une pr\xE9cision adresse, croiser num_finess avec etablissement_by_finess.",
|
|
1151
|
+
structure_finess: "Liste rattach\xE9e \xE0 un FINESS site. Le mode_exercice r\xE9v\xE8le la nature du lien (lib\xE9ral / salari\xE9). Couverture RPPS quand le PS l'a d\xE9clar\xE9 ; salari\xE9s CH/CHU/cliniques bien couverts."
|
|
1152
|
+
};
|
|
1153
|
+
var HAVERSINE_NOTE = "Distance calcul\xE9e en vol d'oiseau (haversine PostGIS). Pour la distance routi\xE8re, croiser avec un service externe (OSRM, ORS).";
|
|
1154
|
+
function buildMetadata(precision, withDistance) {
|
|
1155
|
+
const notes = [SOURCE_NOTE[precision]];
|
|
1156
|
+
const result = { geo_precision: precision, notes };
|
|
1157
|
+
if (withDistance) {
|
|
1158
|
+
result.distance_type = "haversine_postgis";
|
|
1159
|
+
notes.push(HAVERSINE_NOTE);
|
|
1160
|
+
}
|
|
1161
|
+
return result;
|
|
1162
|
+
}
|
|
1163
|
+
var finessRadiusMetadata = () => buildMetadata("lambert93_natif_finess", true);
|
|
1164
|
+
var finessByCategorieMetadata = () => buildMetadata("lambert93_natif_finess", false);
|
|
1165
|
+
var rppsRadiusMetadata = () => buildMetadata("centroide_commune_ans", true);
|
|
1166
|
+
var rppsDeptMetadata = () => buildMetadata("centroide_commune_ans", false);
|
|
1167
|
+
var rppsEtablissementMetadata = () => buildMetadata("structure_finess", false);
|
|
1168
|
+
var anonClient = null;
|
|
1169
|
+
var untypedAnonClient = null;
|
|
1170
|
+
function requireEnv(name) {
|
|
1171
|
+
const value = process.env[name];
|
|
1172
|
+
if (value === void 0) {
|
|
1173
|
+
throw new Error(
|
|
1174
|
+
`[france-data-mcp] Missing required environment variable: ${name}. Set it in .env.local for local dev or in GitHub Secrets for CI/Actions.`
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
if (value === "") {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
`[france-data-mcp] Environment variable ${name} is set but empty. Likely a misconfigured GitHub Secret (renamed/unscoped) or an empty line in .env.local.`
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
return value;
|
|
1183
|
+
}
|
|
1184
|
+
function getAnonClient() {
|
|
1185
|
+
if (!anonClient) {
|
|
1186
|
+
const url = requireEnv("SUPABASE_URL");
|
|
1187
|
+
const key = requireEnv("SUPABASE_ANON_KEY");
|
|
1188
|
+
anonClient = createClient(url, key, {
|
|
1189
|
+
auth: { persistSession: false }
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
return anonClient;
|
|
1193
|
+
}
|
|
1194
|
+
function getUntypedAnonClient() {
|
|
1195
|
+
if (!untypedAnonClient) {
|
|
1196
|
+
const url = requireEnv("SUPABASE_URL");
|
|
1197
|
+
const key = requireEnv("SUPABASE_ANON_KEY");
|
|
1198
|
+
untypedAnonClient = createClient(url, key, { auth: { persistSession: false } });
|
|
1199
|
+
}
|
|
1200
|
+
return untypedAnonClient;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// src/sante/db-helpers.ts
|
|
1204
|
+
var DEFAULT_LIMIT = 100;
|
|
1205
|
+
var MAX_LIMIT = 500;
|
|
1206
|
+
var MAX_OFFSET = 1e5;
|
|
1207
|
+
var RADIUS_MIN_KM = 0.1;
|
|
1208
|
+
var RADIUS_MAX_KM = 50;
|
|
1209
|
+
function clampLimit(limit) {
|
|
1210
|
+
if (limit === void 0) return DEFAULT_LIMIT;
|
|
1211
|
+
if (limit < 1 || limit > MAX_LIMIT) {
|
|
1212
|
+
throw new RangeError(
|
|
1213
|
+
`[france-data-mcp] limit must be between 1 and ${MAX_LIMIT}, got ${limit}`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
return limit;
|
|
1217
|
+
}
|
|
1218
|
+
function clampOffset(offset) {
|
|
1219
|
+
if (offset === void 0) return 0;
|
|
1220
|
+
if (!Number.isFinite(offset) || offset < 0 || offset > MAX_OFFSET) {
|
|
1221
|
+
throw new RangeError(
|
|
1222
|
+
`[france-data-mcp] offset must be between 0 and ${MAX_OFFSET}, got ${offset}`
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
return Math.floor(offset);
|
|
1226
|
+
}
|
|
1227
|
+
function validateRadiusKm(radiusKm) {
|
|
1228
|
+
if (!Number.isFinite(radiusKm) || radiusKm < RADIUS_MIN_KM || radiusKm > RADIUS_MAX_KM) {
|
|
1229
|
+
throw new RangeError(
|
|
1230
|
+
`[france-data-mcp] radiusKm must be in [${RADIUS_MIN_KM}, ${RADIUS_MAX_KM}], got ${radiusKm}`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
function validateCoords(lat, lon) {
|
|
1235
|
+
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
|
|
1236
|
+
throw new RangeError(`[france-data-mcp] lat must be in [-90, 90], got ${lat}`);
|
|
1237
|
+
}
|
|
1238
|
+
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
|
|
1239
|
+
throw new RangeError(`[france-data-mcp] lon must be in [-180, 180], got ${lon}`);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
function formatRpcError(rpc, error) {
|
|
1243
|
+
const code = error.code ? ` (${error.code})` : "";
|
|
1244
|
+
const hint = error.hint ? ` \u2014 hint: ${error.hint}` : "";
|
|
1245
|
+
const details = error.details ? ` \u2014 details: ${error.details}` : "";
|
|
1246
|
+
return `[france-data-mcp] ${rpc}${code}: ${error.message}${details}${hint}`;
|
|
1247
|
+
}
|
|
1248
|
+
function trimOrNull(s) {
|
|
1249
|
+
if (s === null || s === void 0) return null;
|
|
1250
|
+
const trimmed = s.trim();
|
|
1251
|
+
return trimmed === "" ? null : trimmed;
|
|
1252
|
+
}
|
|
1253
|
+
function expectRpcRows(rpc, data) {
|
|
1254
|
+
if (data === null || data === void 0) {
|
|
1255
|
+
throw new Error(
|
|
1256
|
+
`[france-data-mcp] ${rpc}: RPC contract violation \u2014 supabase-js returned no error but data is ${data === null ? "null" : "undefined"}. Expected an array (possibly empty). Investigate RPC name, schema cache, or supabase-js version.`
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
if (!Array.isArray(data)) {
|
|
1260
|
+
throw new Error(
|
|
1261
|
+
`[france-data-mcp] ${rpc}: RPC contract violation \u2014 expected array, got ${typeof data}. Likely an RPC signature mismatch.`
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
return data;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// src/sante/finess-db.ts
|
|
1268
|
+
function familiesToCodes(familles) {
|
|
1269
|
+
if (!familles || familles.length === 0) return [];
|
|
1270
|
+
return familles.flatMap((f) => [...FINESS_FAMILY_CODES[f]]);
|
|
1271
|
+
}
|
|
1272
|
+
async function getFinessInRadius(input) {
|
|
1273
|
+
const limit = clampLimit(input.limit);
|
|
1274
|
+
validateCoords(input.center.lat, input.center.lon);
|
|
1275
|
+
validateRadiusKm(input.radiusKm);
|
|
1276
|
+
const supabase = getAnonClient();
|
|
1277
|
+
const { data, error } = await supabase.rpc("finess_in_radius", {
|
|
1278
|
+
p_lat: input.center.lat,
|
|
1279
|
+
p_lon: input.center.lon,
|
|
1280
|
+
p_radius_meters: input.radiusKm * 1e3,
|
|
1281
|
+
p_codes: familiesToCodes(input.familles),
|
|
1282
|
+
p_limit: limit + 1
|
|
1283
|
+
// +1 to detect truncation
|
|
1284
|
+
});
|
|
1285
|
+
if (error) {
|
|
1286
|
+
throw new Error(formatRpcError("finess_in_radius", error));
|
|
1287
|
+
}
|
|
1288
|
+
return buildFinessQueryResult("finess_in_radius", data, limit, finessRadiusMetadata());
|
|
1289
|
+
}
|
|
1290
|
+
async function getFinessByCategorie(input) {
|
|
1291
|
+
const limit = clampLimit(input.limit);
|
|
1292
|
+
const supabase = getAnonClient();
|
|
1293
|
+
const { data, error } = await supabase.rpc("finess_by_categorie", {
|
|
1294
|
+
p_codes: [...FINESS_FAMILY_CODES[input.famille]],
|
|
1295
|
+
p_departement: input.departement ?? null,
|
|
1296
|
+
p_code_insee: input.code_insee ?? null,
|
|
1297
|
+
p_limit: limit + 1
|
|
1298
|
+
});
|
|
1299
|
+
if (error) {
|
|
1300
|
+
throw new Error(formatRpcError("finess_by_categorie", error));
|
|
1301
|
+
}
|
|
1302
|
+
return buildFinessQueryResult("finess_by_categorie", data, limit, finessByCategorieMetadata());
|
|
1303
|
+
}
|
|
1304
|
+
async function getFinessByNumFiness(numFiness) {
|
|
1305
|
+
if (!/^\d{9}$/.test(numFiness)) {
|
|
1306
|
+
throw new Error(`[france-data-mcp] num_finess must be 9 digits, got "${numFiness}"`);
|
|
1307
|
+
}
|
|
1308
|
+
const supabase = getAnonClient();
|
|
1309
|
+
const { data, error } = await supabase.rpc("finess_by_num_finess", {
|
|
1310
|
+
p_num_finess: numFiness
|
|
1311
|
+
});
|
|
1312
|
+
if (error) {
|
|
1313
|
+
throw new Error(formatRpcError("finess_by_num_finess", error));
|
|
1314
|
+
}
|
|
1315
|
+
const rows = expectRpcRows("finess_by_num_finess", data);
|
|
1316
|
+
if (rows.length > 1) {
|
|
1317
|
+
console.warn(
|
|
1318
|
+
`[france-data-mcp] finess_by_num_finess(${numFiness}): RPC returned ${rows.length} rows (expected \u2264 1) \u2014 picking the first. Investigate finess table for duplicate num_finess.`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
const first = rows[0];
|
|
1322
|
+
if (!first) {
|
|
1323
|
+
return lookupNotFound(
|
|
1324
|
+
numFiness,
|
|
1325
|
+
`Num\xE9ro FINESS "${numFiness}" introuvable dans la base DREES (derni\xE8re sync bimestrielle). Causes possibles : num\xE9ro inexistant, structure tr\xE8s r\xE9cente non encore propag\xE9e par DREES (latence ~1-2 mois), erreur de saisie. Pour structures \xE9mergentes (CPTS, MSP r\xE9centes), cross-check avec ARS r\xE9gionale ou Service Public.`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
return lookupFound(toFinessResult(first));
|
|
1329
|
+
}
|
|
1330
|
+
function buildFinessQueryResult(rpc, data, limit, metadata) {
|
|
1331
|
+
const rows = expectRpcRows(rpc, data);
|
|
1332
|
+
const truncated = rows.length > limit;
|
|
1333
|
+
const sliced = truncated ? rows.slice(0, limit) : rows;
|
|
1334
|
+
return {
|
|
1335
|
+
count: sliced.length,
|
|
1336
|
+
truncated,
|
|
1337
|
+
results: sliced.map(toFinessResult),
|
|
1338
|
+
query_metadata: metadata
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
function toFinessResult(row) {
|
|
1342
|
+
const coords = row.geom ? { lat: row.geom.coordinates[1] ?? 0, lon: row.geom.coordinates[0] ?? 0 } : null;
|
|
1343
|
+
return {
|
|
1344
|
+
num_finess: row.num_finess,
|
|
1345
|
+
raison_sociale: row.raison_sociale,
|
|
1346
|
+
categorie: {
|
|
1347
|
+
code: row.categorie_code,
|
|
1348
|
+
libelle: row.categorie_libelle,
|
|
1349
|
+
famille: finessFamille(row.categorie_code)
|
|
1350
|
+
},
|
|
1351
|
+
adresse: {
|
|
1352
|
+
voie: row.voie,
|
|
1353
|
+
code_postal: trimOrNull(row.code_postal),
|
|
1354
|
+
ville: row.ville,
|
|
1355
|
+
code_departement: trimOrNull(row.code_departement),
|
|
1356
|
+
code_insee: row.code_insee.trim()
|
|
1357
|
+
},
|
|
1358
|
+
coords,
|
|
1359
|
+
distance_km: metersToKm(row.distance_meters),
|
|
1360
|
+
telephone: trimOrNull(row.telephone),
|
|
1361
|
+
email: row.email
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// src/territoire/dept-codes.ts
|
|
1366
|
+
function isValidDept(dept) {
|
|
1367
|
+
if (dept === "2A" || dept === "2B") return true;
|
|
1368
|
+
if (/^\d{2}$/.test(dept)) return dept !== "20";
|
|
1369
|
+
if (/^(97[1-8]|98[4-8])$/.test(dept)) return true;
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
function assertValidDept(dept) {
|
|
1373
|
+
if (isValidDept(dept)) return;
|
|
1374
|
+
throw new RangeError(`[france-data-mcp] departement must be a valid INSEE code, got "${dept}"`);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// src/sante/rpps-types.ts
|
|
1378
|
+
var RPPS_MODE_EXERCICE = {
|
|
1379
|
+
LIBERAL: "L",
|
|
1380
|
+
SALARIE: "S",
|
|
1381
|
+
MIXTE: "M",
|
|
1382
|
+
REMPLACANT: "R",
|
|
1383
|
+
AUTRE: "A",
|
|
1384
|
+
BENEVOLE: "B"
|
|
1385
|
+
};
|
|
1386
|
+
var RPPS_CGU_NOTICE = "Source : Annuaire Sant\xE9, Agence du Num\xE9rique en Sant\xE9 (ANS) \u2014 Licence Ouverte v2.0";
|
|
1387
|
+
|
|
1388
|
+
// src/sante/rpps-db.ts
|
|
1389
|
+
var CATEGORIE_CODE_CIVIL = "C";
|
|
1390
|
+
var CATEGORIE_CODES_DEFAUT = Object.freeze([
|
|
1391
|
+
CATEGORIE_CODE_CIVIL
|
|
1392
|
+
]);
|
|
1393
|
+
async function getRppsInRadius(input) {
|
|
1394
|
+
const limit = clampLimit(input.limit);
|
|
1395
|
+
validateCoords(input.center.lat, input.center.lon);
|
|
1396
|
+
validateRadiusKm(input.radiusKm);
|
|
1397
|
+
const supabase = getUntypedAnonClient();
|
|
1398
|
+
const { data, error } = await supabase.rpc("rpps_in_radius", {
|
|
1399
|
+
p_lat: input.center.lat,
|
|
1400
|
+
p_lon: input.center.lon,
|
|
1401
|
+
p_radius_meters: input.radiusKm * 1e3,
|
|
1402
|
+
p_profession_codes: input.professionCodes ?? [],
|
|
1403
|
+
p_savoir_faire_codes: input.savoirFaireCodes ?? [],
|
|
1404
|
+
p_mode_exercice_codes: input.modeExerciceCodes ?? [],
|
|
1405
|
+
p_categorie_codes: input.categorieCodes ?? [],
|
|
1406
|
+
p_limit: limit + 1
|
|
1407
|
+
});
|
|
1408
|
+
if (error) throw new Error(formatRpcError("rpps_in_radius", error));
|
|
1409
|
+
return buildQueryResult("rpps_in_radius", data, limit, rppsRadiusMetadata());
|
|
1410
|
+
}
|
|
1411
|
+
async function getRppsParSpecialiteDept(input) {
|
|
1412
|
+
const limit = clampLimit(input.limit);
|
|
1413
|
+
const offset = clampOffset(input.offset);
|
|
1414
|
+
assertValidDept(input.departement);
|
|
1415
|
+
const supabase = getUntypedAnonClient();
|
|
1416
|
+
const categorieCodes = input.categorieCodes && input.categorieCodes.length > 0 ? input.categorieCodes : [...CATEGORIE_CODES_DEFAUT];
|
|
1417
|
+
const { data, error } = await supabase.rpc("rpps_par_specialite_dept", {
|
|
1418
|
+
p_departement: input.departement,
|
|
1419
|
+
p_profession_code: input.professionCode ?? null,
|
|
1420
|
+
p_savoir_faire_code: input.savoirFaireCode ?? null,
|
|
1421
|
+
p_mode_exercice_code: input.modeExerciceCode ?? null,
|
|
1422
|
+
p_categorie_codes: categorieCodes,
|
|
1423
|
+
p_limit: limit + 1,
|
|
1424
|
+
p_offset: offset
|
|
1425
|
+
});
|
|
1426
|
+
if (error) throw new Error(formatRpcError("rpps_par_specialite_dept", error));
|
|
1427
|
+
return buildQueryResult("rpps_par_specialite_dept", data, limit, rppsDeptMetadata());
|
|
1428
|
+
}
|
|
1429
|
+
async function getRppsDansEtablissement(input) {
|
|
1430
|
+
const limit = clampLimit(input.limit);
|
|
1431
|
+
const numFiness = input.numFiness.trim();
|
|
1432
|
+
if (!/^\d{9}$/.test(numFiness)) {
|
|
1433
|
+
throw new RangeError(
|
|
1434
|
+
`[france-data-mcp] num_finess invalide "${input.numFiness}" \u2014 attendu 9 chiffres (FINESS site).`
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
const supabase = getUntypedAnonClient();
|
|
1438
|
+
const { data, error } = await supabase.rpc("rpps_dans_etablissement", {
|
|
1439
|
+
p_num_finess: numFiness,
|
|
1440
|
+
p_categorie_codes: input.categorieCodes ?? [],
|
|
1441
|
+
p_limit: limit + 1
|
|
1442
|
+
});
|
|
1443
|
+
if (error) throw new Error(formatRpcError("rpps_dans_etablissement", error));
|
|
1444
|
+
const rows = expectRpcRows("rpps_dans_etablissement", data);
|
|
1445
|
+
const truncated = rows.length > limit;
|
|
1446
|
+
const sliced = truncated ? rows.slice(0, limit) : rows;
|
|
1447
|
+
return {
|
|
1448
|
+
count: sliced.length,
|
|
1449
|
+
truncated,
|
|
1450
|
+
results: sliced.map(toCompactResult),
|
|
1451
|
+
query_metadata: rppsEtablissementMetadata()
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
async function getRppsById(rppsId) {
|
|
1455
|
+
const trimmed = rppsId.trim();
|
|
1456
|
+
if (!/^\d{11,12}$/.test(trimmed)) {
|
|
1457
|
+
throw new RangeError(
|
|
1458
|
+
`[france-data-mcp] rpps_id invalide "${rppsId}" \u2014 attendu 11 ou 12 chiffres (IDNPS national, format ANS \u2014 pr\xE9fixe "81" optionnel pour les IDs \xE9mis depuis 2020 = 12 chars, sans pr\xE9fixe = 11 chars).`
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
const supabase = getUntypedAnonClient();
|
|
1462
|
+
const { data, error } = await supabase.rpc("rpps_lookup_by_id", {
|
|
1463
|
+
p_rpps_id: trimmed
|
|
1464
|
+
});
|
|
1465
|
+
if (error) throw new Error(formatRpcError("rpps_lookup_by_id", error));
|
|
1466
|
+
const rows = expectRpcRows("rpps_lookup_by_id", data);
|
|
1467
|
+
return rows.map(toLookupResult);
|
|
1468
|
+
}
|
|
1469
|
+
function buildQueryResult(rpc, data, limit, metadata) {
|
|
1470
|
+
const rows = expectRpcRows(rpc, data);
|
|
1471
|
+
const truncated = rows.length > limit;
|
|
1472
|
+
const sliced = truncated ? rows.slice(0, limit) : rows;
|
|
1473
|
+
return {
|
|
1474
|
+
count: sliced.length,
|
|
1475
|
+
truncated,
|
|
1476
|
+
results: sliced.map(toResult),
|
|
1477
|
+
query_metadata: metadata
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function toResult(row) {
|
|
1481
|
+
const lat = row.geom?.coordinates[1];
|
|
1482
|
+
const lon = row.geom?.coordinates[0];
|
|
1483
|
+
const coords = typeof lat === "number" && typeof lon === "number" ? { lat, lon } : null;
|
|
1484
|
+
return {
|
|
1485
|
+
id: row.id,
|
|
1486
|
+
rpps_id: row.rpps_id,
|
|
1487
|
+
identite: {
|
|
1488
|
+
nom: row.nom,
|
|
1489
|
+
prenom: row.prenom,
|
|
1490
|
+
civilite: row.civilite
|
|
1491
|
+
},
|
|
1492
|
+
profession: { code: row.profession_code, libelle: row.profession_libelle },
|
|
1493
|
+
savoir_faire: { code: row.savoir_faire_code, libelle: row.savoir_faire_libelle },
|
|
1494
|
+
mode_exercice: { code: row.mode_exercice_code, libelle: row.mode_exercice_libelle },
|
|
1495
|
+
categorie: { code: row.categorie_code, libelle: row.categorie_libelle },
|
|
1496
|
+
structure: {
|
|
1497
|
+
num_finess: row.num_finess,
|
|
1498
|
+
num_finess_ej: row.num_finess_ej,
|
|
1499
|
+
siret: row.siret,
|
|
1500
|
+
raison_sociale: row.raison_sociale
|
|
1501
|
+
},
|
|
1502
|
+
adresse: {
|
|
1503
|
+
voie: row.adresse,
|
|
1504
|
+
// CHAR(N) Postgres pad avec espaces — trim systématique pour ne pas
|
|
1505
|
+
// leak `"08 "` côté caller (cohérent finess-db.ts / ameli-db.ts).
|
|
1506
|
+
code_postal: trimOrNull(row.code_postal),
|
|
1507
|
+
ville: row.ville,
|
|
1508
|
+
code_departement: trimOrNull(row.code_departement),
|
|
1509
|
+
code_insee: trimOrNull(row.code_insee)
|
|
1510
|
+
},
|
|
1511
|
+
coords,
|
|
1512
|
+
distance_km: metersToKm(row.distance_meters),
|
|
1513
|
+
telephone: row.telephone
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function toCompactResult(row) {
|
|
1517
|
+
return {
|
|
1518
|
+
id: row.id,
|
|
1519
|
+
rpps_id: row.rpps_id,
|
|
1520
|
+
identite: { nom: row.nom, prenom: row.prenom, civilite: row.civilite },
|
|
1521
|
+
profession: { code: row.profession_code, libelle: row.profession_libelle },
|
|
1522
|
+
savoir_faire: { code: row.savoir_faire_code, libelle: row.savoir_faire_libelle },
|
|
1523
|
+
mode_exercice: { code: row.mode_exercice_code, libelle: row.mode_exercice_libelle },
|
|
1524
|
+
categorie: { code: row.categorie_code, libelle: row.categorie_libelle },
|
|
1525
|
+
structure: {
|
|
1526
|
+
num_finess: row.num_finess,
|
|
1527
|
+
num_finess_ej: row.num_finess_ej,
|
|
1528
|
+
siret: null,
|
|
1529
|
+
raison_sociale: row.raison_sociale
|
|
1530
|
+
},
|
|
1531
|
+
adresse: {
|
|
1532
|
+
voie: null,
|
|
1533
|
+
code_postal: null,
|
|
1534
|
+
ville: null,
|
|
1535
|
+
code_departement: null,
|
|
1536
|
+
code_insee: null
|
|
1537
|
+
},
|
|
1538
|
+
coords: null,
|
|
1539
|
+
distance_km: null,
|
|
1540
|
+
telephone: row.telephone
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
function toLookupResult(row) {
|
|
1544
|
+
return {
|
|
1545
|
+
...toResult(row),
|
|
1546
|
+
identifiant_pp: row.identifiant_pp,
|
|
1547
|
+
siren: row.siren,
|
|
1548
|
+
email: row.email
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/sante/ans-fhir.ts
|
|
1553
|
+
var ANS_FHIR_DEFAULT_BASE = "https://gateway.api.esante.gouv.fr/fhir/v2";
|
|
1554
|
+
var ANS_AUTH_HEADER = "ESANTE-API-KEY";
|
|
1555
|
+
var IDNPS_SYSTEM = "urn:oid:1.2.250.1.71.4.2.1";
|
|
1556
|
+
var FETCH_TIMEOUT_MS2 = 6e4;
|
|
1557
|
+
function getAnsFhirApiKey() {
|
|
1558
|
+
const raw = process.env.ANS_FHIR_API_KEY;
|
|
1559
|
+
if (!raw) return null;
|
|
1560
|
+
const cleaned = raw.trim().replace(/^["']|["']$/g, "");
|
|
1561
|
+
return cleaned === "" ? null : cleaned;
|
|
1562
|
+
}
|
|
1563
|
+
function getAnsFhirBaseUrl() {
|
|
1564
|
+
const raw = process.env.ANS_FHIR_BASE_URL?.trim();
|
|
1565
|
+
return raw && raw !== "" ? raw.replace(/\/+$/, "") : ANS_FHIR_DEFAULT_BASE;
|
|
1566
|
+
}
|
|
1567
|
+
async function lookupPractitionerByRpps(rppsId) {
|
|
1568
|
+
const apiKey = getAnsFhirApiKey();
|
|
1569
|
+
if (!apiKey) {
|
|
1570
|
+
return {
|
|
1571
|
+
found: false,
|
|
1572
|
+
status: "no_key",
|
|
1573
|
+
message: "ESANTE-API-KEY non configur\xE9e c\xF4t\xE9 serveur. Fallback FHIR ANS indisponible \u2014 s'appuyer sur la DB locale (snapshot mensuel J-30 max)."
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
const trimmed = rppsId.trim();
|
|
1577
|
+
if (trimmed === "" || !/^\d{11,12}$/.test(trimmed)) {
|
|
1578
|
+
console.warn(
|
|
1579
|
+
`[france-data-mcp] ANS FHIR lookup skipped \u2014 rpps_id "${rppsId}" rejet\xE9 par la garde format /^\\d{11,12}$/.`
|
|
1580
|
+
);
|
|
1581
|
+
return {
|
|
1582
|
+
found: false,
|
|
1583
|
+
status: "invalid_format",
|
|
1584
|
+
message: `rpps_id "${rppsId}" invalide \u2014 format IDNPS attendu : 11 ou 12 chiffres.`
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
const baseUrl = getAnsFhirBaseUrl();
|
|
1588
|
+
const url = `${baseUrl}/Practitioner?identifier=${encodeURIComponent(IDNPS_SYSTEM)}|${encodeURIComponent(trimmed)}`;
|
|
1589
|
+
const controller = new AbortController();
|
|
1590
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS2);
|
|
1591
|
+
let bundle;
|
|
1592
|
+
try {
|
|
1593
|
+
bundle = await fetchJson(url, {
|
|
1594
|
+
headers: {
|
|
1595
|
+
[ANS_AUTH_HEADER]: apiKey,
|
|
1596
|
+
Accept: "application/fhir+json"
|
|
1597
|
+
},
|
|
1598
|
+
signal: controller.signal
|
|
1599
|
+
});
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
const httpStatus = err instanceof HttpError ? err.status : null;
|
|
1602
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1603
|
+
const detail = httpStatus !== null ? `HTTP ${httpStatus}` : `network/parse error: ${errMsg}`;
|
|
1604
|
+
const logFn = httpStatus === 404 ? console.warn : console.error;
|
|
1605
|
+
logFn(`[france-data-mcp] ANS FHIR lookup terminated for rpps=${trimmed} \u2014 ${detail}`);
|
|
1606
|
+
if (httpStatus === 404) {
|
|
1607
|
+
return {
|
|
1608
|
+
found: false,
|
|
1609
|
+
status: "not_found",
|
|
1610
|
+
message: `IDNPS ${trimmed} introuvable c\xF4t\xE9 ANS FHIR (HTTP 404).`
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
return {
|
|
1614
|
+
found: false,
|
|
1615
|
+
status: "api_error",
|
|
1616
|
+
message: `ANS FHIR lookup failed for rpps=${trimmed} \u2014 ${detail}. Retry recommand\xE9.`
|
|
1617
|
+
};
|
|
1618
|
+
} finally {
|
|
1619
|
+
clearTimeout(timeout);
|
|
1620
|
+
}
|
|
1621
|
+
const entries = bundle.entry ?? [];
|
|
1622
|
+
if (entries.length === 0) {
|
|
1623
|
+
return {
|
|
1624
|
+
found: false,
|
|
1625
|
+
status: "not_found",
|
|
1626
|
+
message: `IDNPS ${trimmed} introuvable c\xF4t\xE9 ANS FHIR (Bundle vide).`
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
const resource = entries[0]?.resource;
|
|
1630
|
+
if (!resource || resource.resourceType !== "Practitioner") {
|
|
1631
|
+
console.warn(
|
|
1632
|
+
`[france-data-mcp] ANS FHIR Practitioner attendu mais resource=${resource?.resourceType ?? "null"} pour rpps=${trimmed} \u2014 r\xE9ponse incoh\xE9rente`
|
|
1633
|
+
);
|
|
1634
|
+
return {
|
|
1635
|
+
found: false,
|
|
1636
|
+
status: "api_error",
|
|
1637
|
+
message: `ANS FHIR a renvoy\xE9 un payload incoh\xE9rent pour ${trimmed} (resourceType inattendu).`
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
return { found: true, practitioner: mapPractitioner(resource, trimmed) };
|
|
1641
|
+
}
|
|
1642
|
+
function mapPractitioner(resource, expectedRpps) {
|
|
1643
|
+
const ansInternalId = resource.id ?? "";
|
|
1644
|
+
const idnps = resource.identifier?.find(
|
|
1645
|
+
(id) => id.system === IDNPS_SYSTEM || id.type?.coding?.some((c) => c.code === "IDNPS")
|
|
1646
|
+
);
|
|
1647
|
+
const idnpsValue = idnps?.value?.trim();
|
|
1648
|
+
if (!idnpsValue) {
|
|
1649
|
+
console.warn(
|
|
1650
|
+
`[france-data-mcp] ANS FHIR Practitioner (id=${resource.id ?? "?"}) sans IDNPS exploitable \u2014 fallback sur la valeur URL "${expectedRpps}"`
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
const rpps_id = idnpsValue || expectedRpps;
|
|
1654
|
+
const officialName = resource.name?.find((n) => n.use === "official") ?? resource.name?.[0];
|
|
1655
|
+
const family = officialName?.family?.trim() ?? "";
|
|
1656
|
+
const given = officialName?.given?.[0]?.trim() ?? "";
|
|
1657
|
+
const civilite = officialName?.prefix?.[0]?.trim() ?? null;
|
|
1658
|
+
return {
|
|
1659
|
+
ans_internal_id: ansInternalId,
|
|
1660
|
+
rpps_id,
|
|
1661
|
+
civilite,
|
|
1662
|
+
nom: family,
|
|
1663
|
+
prenom: given,
|
|
1664
|
+
active: typeof resource.active === "boolean" ? resource.active : null,
|
|
1665
|
+
source: "ans_fhir"
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// src/sante/index.ts
|
|
1670
|
+
var SANTE_VERSION = "0.5.1";
|
|
1671
|
+
|
|
1672
|
+
export { FINESS_CATEGORIES, FINESS_EHPAD, FINESS_FAMILY_CODES, FINESS_HOPITAUX, FINESS_LABOS, FINESS_MSP_CPTS, FINESS_PHARMACIES, NAF_EHPAD, NAF_LABOS, NAF_MEDECINE_VILLE, NAF_PHARMACIES, NAF_SANTE, RPPS_CGU_NOTICE, RPPS_MODE_EXERCICE, SANTE_VERSION, TERRITOIRE_VERSION, ensureAnnuaireAmeli, finessFamille, geocode, geocodeMany, getAnsFhirApiKey, getAnsFhirBaseUrl, getCommuneByCode, getEntrepriseBySiren, getFinessByCategorie, getFinessByNumFiness, getFinessInRadius, getInseeApiKey, getRppsById, getRppsDansEtablissement, getRppsInRadius, getRppsParSpecialiteDept, haversineDistance, libelleCategorieFiness, libelleNaf, loadFiness, loadProfessionnels, lookupPractitionerByRpps, lookupSirenViaInsee, reverseGeocode, searchCommunes, searchEntreprises, searchEtablissementsFiness, streamProfessionnels };
|
|
1673
|
+
//# sourceMappingURL=index.js.map
|
|
1674
|
+
//# sourceMappingURL=index.js.map
|