mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 16:07:10 +00:00
V1.23.28 Mise à jour de la BDD des entreprises et stratégies
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Calcule et stocke, pour chaque commune de ENT_Communes, le nombre d'entreprises
|
||||
actives par categorie de taille (EI, TPE, PME, ETI, GE) -- pre-calcule pour que
|
||||
le futur module de recherche par perimetre geographique n'ait jamais a compter
|
||||
en temps reel sur les tables Entreprises*Phase1 (qui grossissent).
|
||||
|
||||
Regle de classification (une entreprise tombe dans une seule categorie) :
|
||||
- ENT_CategorieEntreprise = 'GE' -> GE
|
||||
- ENT_CategorieEntreprise = 'ETI' -> ETI
|
||||
- ENT_CategorieEntreprise = 'PME' -> PME
|
||||
- categorie NULL et nature juridique = entrepreneur individuel (1er
|
||||
chiffre de ENT_NatureJuridique = '1') -> EI
|
||||
- categorie NULL, tout le reste -> TPE
|
||||
(ENT_CategorieEntreprise, quand renseignee par l'INSEE, est deja fiable et
|
||||
prioritaire ; les entreprises non categorisees par l'INSEE -- la grande
|
||||
majorite -- sont reparties entre EI et TPE selon leur forme juridique.)
|
||||
|
||||
Concu pour tourner une fois par jour (cf ENT_Communes.COM_DateMajStats) : idem
|
||||
potent, recalcule et ecrase les compteurs a chaque execution, jamais un delta.
|
||||
|
||||
Usage :
|
||||
python3 ent_communes_stats.py
|
||||
python3 ent_communes_stats.py --departements 56,29
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def load_env(path):
|
||||
env = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip()
|
||||
return env
|
||||
|
||||
|
||||
def compute_departement(conn, dep):
|
||||
table1 = f"Entreprises{dep}Phase1"
|
||||
# IMPORTANT : Entreprises<dep>Phase1 contient aussi des entreprises dont le
|
||||
# siege est dans un AUTRE departement (etablissement secondaire dans <dep> --
|
||||
# la "pollution" deja identifiee). On restreint donc explicitement aux
|
||||
# communes qui appartiennent reellement a <dep> (via ENT_Communes), pour
|
||||
# qu'un departement ne puisse jamais ecraser les stats d'une commune qui ne
|
||||
# lui appartient pas avec un sous-ensemble incomplet.
|
||||
sql = f"""
|
||||
SELECT
|
||||
t.ENT_CodeCommune,
|
||||
COALESCE(SUM(t.ENT_CategorieEntreprise = 'GE'), 0) AS ge,
|
||||
COALESCE(SUM(t.ENT_CategorieEntreprise = 'ETI'), 0) AS eti,
|
||||
COALESCE(SUM(t.ENT_CategorieEntreprise = 'PME'), 0) AS pme,
|
||||
COALESCE(SUM(t.ENT_CategorieEntreprise IS NULL AND LEFT(t.ENT_NatureJuridique, 1) = '1'), 0) AS ei,
|
||||
COALESCE(SUM(t.ENT_CategorieEntreprise IS NULL AND
|
||||
(t.ENT_NatureJuridique IS NULL OR LEFT(t.ENT_NatureJuridique, 1) != '1')), 0) AS tpe
|
||||
FROM `{table1}` t
|
||||
INNER JOIN ENT_Communes c
|
||||
ON c.COM_CodeInsee COLLATE utf8mb4_0900_ai_ci = t.ENT_CodeCommune AND c.COM_Departement = %s
|
||||
WHERE t.ENT_CodeCommune IS NOT NULL
|
||||
GROUP BY t.ENT_CodeCommune
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (dep,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for code_commune, ge, eti, pme, ei, tpe in rows:
|
||||
cur.execute(
|
||||
"UPDATE ENT_Communes SET COM_EI=%s, COM_TPE=%s, COM_PME=%s, COM_ETI=%s, "
|
||||
"COM_GE=%s, COM_DateMajStats=NOW() WHERE COM_CodeInsee=%s",
|
||||
(ei, tpe, pme, eti, ge, code_commune),
|
||||
)
|
||||
log(f" {dep} : {len(rows)} communes mises a jour")
|
||||
return len(rows)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--departements", default=None,
|
||||
help="Liste separee par des virgules (defaut : tous ceux de ENT_Departements)")
|
||||
ap.add_argument("--env-file", default=os.path.join(os.path.dirname(__file__), ".env"))
|
||||
args = ap.parse_args()
|
||||
|
||||
env = load_env(args.env_file)
|
||||
conn = pymysql.connect(
|
||||
host=env["ENTREPRISES_DB_HOST"], port=int(env.get("ENTREPRISES_DB_PORT", 3306)),
|
||||
user=env["ENTREPRISES_DB_USER"], password=env["ENTREPRISES_DB_PASS"],
|
||||
database=env["ENTREPRISES_DB_NAME"], charset="utf8mb4", autocommit=True,
|
||||
)
|
||||
|
||||
if args.departements:
|
||||
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT DEP_Code FROM ENT_Departements ORDER BY DEP_Code")
|
||||
departements = [row[0] for row in cur.fetchall()]
|
||||
|
||||
log(f"Departements a traiter : {departements}")
|
||||
total = 0
|
||||
try:
|
||||
for dep in departements:
|
||||
total += compute_departement(conn, dep)
|
||||
finally:
|
||||
conn.close()
|
||||
log(f"Termine : {total} communes mises a jour au total")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill historique des comptes annuels (API RNE/INPI, /api/bilans-saisis)
|
||||
dans EntreprisesXXPhase3, pour toutes les entreprises deja connues (une ligne
|
||||
par SIREN+annee, jamais ecrasee -- voir la conception Phase3).
|
||||
|
||||
Contrairement a l'extraction Sirene (sirene_extract.py), cet appel API n'est
|
||||
pas filtrable par departement : on l'interroge par lot de SIREN (max ~300
|
||||
avant de depasser la limite de longueur d'URL du serveur, on utilise 250 par
|
||||
securite), sans limite de date, pour recuperer tout l'historique disponible
|
||||
(comptes annuels dispo depuis 2017 selon l'espace personnel INPI).
|
||||
|
||||
Les donnees saisies sont une liasse fiscale brute (codes CERFA), pas un CA/
|
||||
resultat net direct -- la table TYPE_BILAN_MAPPING ci-dessous traduit les
|
||||
codes pertinents pour chaque type de bilan (C, S, K, AC, AS, B), etablie le
|
||||
12 septembre 2026 a partir de la doc officielle INPI (v5, juin 2025) et
|
||||
verifiee empiriquement sur un vrai bilan (EDF, SIREN 552081317).
|
||||
|
||||
Regle de colonne (verifiee empiriquement) : quand un code liasse a une
|
||||
colonne m3, elle contient le total de l'exercice en cours (m4 = exercice
|
||||
precedent) ; sinon c'est m1 qui est l'exercice en cours.
|
||||
|
||||
Les bilans en confidentialite non publique ne sont pas exploites (CA/
|
||||
resultat net non extraits), par respect du choix de confidentialite declare.
|
||||
|
||||
Usage :
|
||||
python3 inpi_bilans_backfill.py
|
||||
python3 inpi_bilans_backfill.py --departements 56,29
|
||||
|
||||
Identifiants lus depuis .env (ENTREPRISES_DB_*, INPI_USERNAME, INPI_PASSWORD)
|
||||
dans le meme dossier, jamais en argument.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import pymysql
|
||||
|
||||
class QuotaExceededError(Exception):
|
||||
"""Quota de l'API INPI depasse (distinct d'une simple limitation de debit
|
||||
passagere) -- inutile de retenter, il faut arreter et laisser le quota se
|
||||
regenerer (journalier selon toute vraisemblance) avant de reprendre."""
|
||||
|
||||
|
||||
API_LOGIN = "https://registre-national-entreprises.inpi.fr/api/sso/login"
|
||||
API_BILANS_SAISIS = "https://registre-national-entreprises.inpi.fr/api/bilans-saisis"
|
||||
BATCH_SIZE = 250 # 300 confirme fonctionnel, marge de securite prise
|
||||
PAGE_SIZE = 100
|
||||
RATE_DELAY = 0.2
|
||||
|
||||
# code liasse -> [codes a sommer pour le CA] / [codes a sommer pour le resultat net]
|
||||
TYPE_BILAN_MAPPING = {
|
||||
"C": {"ca": ["FJ"], "rn": ["HN"]},
|
||||
"S": {"ca": ["209", "210", "214", "215", "217", "218"], "rn": ["310"]},
|
||||
"K": {"ca": ["FJ"], "rn": ["R6"]},
|
||||
"AC": {"ca": ["FP"], "rn": ["HO"]},
|
||||
"AS": {"ca": ["EA", "EB", "EC", "ED", "EE"], "rn": ["FJ"]},
|
||||
"B": {"ca": [], "rn": ["R3"]},
|
||||
}
|
||||
|
||||
# Priorite quand plusieurs bilans existent pour le meme SIREN+annee (frequent :
|
||||
# une societe mere depose a la fois ses comptes individuels et consolides).
|
||||
# On privilegie systematiquement les comptes de l'entite elle-meme (C, AC, AS,
|
||||
# S) a un chiffre de groupe consolide (K) qui ne represente plus la societe
|
||||
# precise -- et B (banque) en tout dernier recours seulement.
|
||||
TYPE_PRIORITY = {"C": 0, "AC": 0, "AS": 1, "S": 2, "K": 3, "B": 4}
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def load_env(path):
|
||||
env = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip()
|
||||
return env
|
||||
|
||||
|
||||
def http_get_json(url, auth, retries=8):
|
||||
# "auth" est un dict mutable {"token", "username", "password"} plutot qu'un
|
||||
# jeton brut, pour pouvoir se reconnecter tout seul sur un 401 (jeton perime
|
||||
# en cours de traitement, constate le 14/09/2026 sur une execution de plusieurs
|
||||
# heures -- l'ancien code retentait 8 fois avec le meme jeton mort, ce qui ne
|
||||
# pouvait jamais reussir, et perdait tous les lots restants du departement).
|
||||
for attempt in range(retries):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {auth['token']}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
log(" !! 401 Unauthorized -- jeton probablement expire, reconnexion")
|
||||
auth["token"] = login(auth["username"], auth["password"])
|
||||
continue
|
||||
if e.code == 429:
|
||||
# Deux cas bien distincts derriere le meme code HTTP 429, constates
|
||||
# le 14/09/2026 : une limitation de debit passagere (retentable avec
|
||||
# patience) et un vrai quota de service depasse (corps de reponse
|
||||
# {"errorCode":"quota_exceeded","webserviceCode":"QUOTA_SERVICE"} --
|
||||
# la, retenter ne sert a rien, il faut arreter tout de suite. Avant
|
||||
# cette distinction, le script s'est acharne des heures a retenter
|
||||
# un quota epuise, perdant lot apres lot pour rien.
|
||||
try:
|
||||
body = json.loads(e.read())
|
||||
except Exception:
|
||||
body = {}
|
||||
if body.get("errorCode") == "quota_exceeded":
|
||||
raise QuotaExceededError(body.get("message", "quota INPI depasse"))
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
retry_after = e.headers.get("Retry-After") if e.headers else None
|
||||
wait = int(retry_after) if retry_after and retry_after.isdigit() else min(10 * (2 ** attempt), 300)
|
||||
log(f" !! 429 Too Many Requests -- pause de {wait}s avant nouvelle tentative "
|
||||
f"({attempt + 1}/{retries})")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
log(f" retry apres erreur reseau ({e})")
|
||||
time.sleep(2 + attempt)
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
log(f" retry apres erreur reseau ({e})")
|
||||
time.sleep(2 + attempt)
|
||||
|
||||
|
||||
def login(username, password):
|
||||
req = urllib.request.Request(
|
||||
API_LOGIN,
|
||||
data=json.dumps({"username": username, "password": password}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.load(resp)["token"]
|
||||
|
||||
|
||||
def get_liasse_value(pages, code):
|
||||
for page in pages:
|
||||
for l in page.get("liasses", []):
|
||||
if l.get("code") == code:
|
||||
raw = l.get("m3") if l.get("m3") not in (None, "") else l.get("m1")
|
||||
if raw in (None, ""):
|
||||
continue
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def sum_codes(pages, codes):
|
||||
if not codes:
|
||||
return None
|
||||
total = 0
|
||||
found_any = False
|
||||
for code in codes:
|
||||
v = get_liasse_value(pages, code)
|
||||
if v is not None:
|
||||
total += v
|
||||
found_any = True
|
||||
return total if found_any else None
|
||||
|
||||
|
||||
def parse_bilan(entry):
|
||||
"""Retourne (annee, type_bilan, ca, resultat_net) ou None si non exploitable."""
|
||||
if entry.get("deleted"):
|
||||
return None
|
||||
if entry.get("confidentiality") not in (None, "Public"):
|
||||
return None
|
||||
bilan_saisi = entry.get("bilanSaisi")
|
||||
if not bilan_saisi:
|
||||
return None
|
||||
bilan = bilan_saisi.get("bilan", {})
|
||||
identite = bilan.get("identite", {})
|
||||
type_bilan = identite.get("codeTypeBilan")
|
||||
mapping = TYPE_BILAN_MAPPING.get(type_bilan)
|
||||
if mapping is None:
|
||||
return None
|
||||
date_cloture = identite.get("dateClotureExercice")
|
||||
if not date_cloture or len(date_cloture) < 4:
|
||||
return None
|
||||
annee = date_cloture[:4]
|
||||
pages = bilan.get("detail", {}).get("pages", [])
|
||||
ca = sum_codes(pages, mapping["ca"])
|
||||
rn = sum_codes(pages, mapping["rn"])
|
||||
if ca is None and rn is None:
|
||||
return None
|
||||
return annee, type_bilan, ca, rn
|
||||
|
||||
|
||||
MAX_PAGES_PAR_LOT = 100 # garde-fou : 100*PAGE_SIZE = 10 000 resultats max par lot,
|
||||
# au-dela quelque chose ne tourne pas rond (curseur bloque
|
||||
# ou volume anormal) -- ne jamais boucler indefiniment.
|
||||
|
||||
|
||||
def fetch_bilans_for_batch(sirens, auth):
|
||||
# Le curseur searchAfter doit porter sur l'identifiant unique du document
|
||||
# (champ "id", ex. un ObjectId), jamais sur le SIREN : un SIREN a en general
|
||||
# plusieurs bilans (plusieurs annees, parfois individuel + consolide), donc
|
||||
# il revient plusieurs fois dans les resultats -- l'utiliser comme curseur
|
||||
# provoquait un faux "curseur bloque" des qu'un SIREN se trouvait en
|
||||
# dernniere position d'une page, abandonnant le lot prematurement et
|
||||
# perdant les bilans non encore recuperes (bug trouve le 14/09/2026 :
|
||||
# ~14% des lots du departement 31 perdaient ainsi une partie de leur
|
||||
# historique). Verifie empiriquement : le champ "id" donne une pagination
|
||||
# propre, sans doublon ni repli, contrairement au SIREN.
|
||||
out = []
|
||||
search_after = None
|
||||
pages_lues = 0
|
||||
while True:
|
||||
pages_lues += 1
|
||||
if pages_lues > MAX_PAGES_PAR_LOT:
|
||||
log(f" !! lot bloque apres {MAX_PAGES_PAR_LOT} pages "
|
||||
f"(searchAfter={search_after}) -- abandon de ce lot pour eviter une "
|
||||
f"boucle infinie, {len(out)} resultats deja recuperes conserves")
|
||||
break
|
||||
params = [("siren[]", s) for s in sirens]
|
||||
params.append(("pageSize", str(PAGE_SIZE)))
|
||||
if search_after:
|
||||
params.append(("searchAfter", search_after))
|
||||
url = API_BILANS_SAISIS + "?" + urllib.parse.urlencode(params)
|
||||
data = http_get_json(url, auth)
|
||||
time.sleep(RATE_DELAY)
|
||||
if not data:
|
||||
break
|
||||
out.extend(data)
|
||||
if len(data) < PAGE_SIZE:
|
||||
break
|
||||
nouveau_search_after = data[-1].get("id")
|
||||
if not nouveau_search_after or nouveau_search_after == search_after:
|
||||
log(f" !! curseur searchAfter bloque sur {nouveau_search_after} -- "
|
||||
f"abandon de ce lot, {len(out)} resultats deja recuperes conserves")
|
||||
break
|
||||
search_after = nouveau_search_after
|
||||
return out
|
||||
|
||||
|
||||
def upsert_finance(conn, table, siren, annee, ca, rn):
|
||||
sql = (
|
||||
f"INSERT INTO `{table}` (ENT_Siren, ENT_FinancesAnnee, ENT_FinancesCa, ENT_FinancesResultatNet) "
|
||||
f"VALUES (%s,%s,%s,%s) "
|
||||
f"ON DUPLICATE KEY UPDATE ENT_FinancesCa=VALUES(ENT_FinancesCa), "
|
||||
f"ENT_FinancesResultatNet=VALUES(ENT_FinancesResultatNet)"
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (siren, annee, ca, rn))
|
||||
|
||||
|
||||
def backfill_departement(conn, dep, auth):
|
||||
table1 = f"Entreprises{dep}Phase1"
|
||||
table3 = f"Entreprises{dep}Phase3"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT ENT_Siren FROM `{table1}`")
|
||||
sirens = [row[0] for row in cur.fetchall()]
|
||||
|
||||
log(f"=== Departement {dep} : {len(sirens)} entreprises a traiter ===")
|
||||
total_rows = 0
|
||||
total_ignores_confidentiel = 0
|
||||
total_types_inconnus = 0
|
||||
unknown_types = {}
|
||||
|
||||
for i in range(0, len(sirens), BATCH_SIZE):
|
||||
batch = sirens[i:i + BATCH_SIZE]
|
||||
try:
|
||||
entries = fetch_bilans_for_batch(batch, auth)
|
||||
except QuotaExceededError:
|
||||
log(f" !! quota INPI depasse au lot {i}-{i+len(batch)} -- arret immediat du "
|
||||
f"departement {dep} (inutile de continuer, le quota est global au compte, "
|
||||
f"pas au departement)")
|
||||
raise
|
||||
except Exception as e:
|
||||
log(f" !! lot {i}-{i+len(batch)} : erreur {e}, on continue")
|
||||
continue
|
||||
|
||||
# best[(siren, annee)] = (priorite, ca, rn) -- on ne garde que le
|
||||
# bilan de plus haute priorite (cf TYPE_PRIORITY) par SIREN+annee,
|
||||
# pour ne jamais laisser un bilan consolide (K) ecraser au hasard
|
||||
# le bilan individuel (C) de la meme annee, ou l'inverse.
|
||||
best = {}
|
||||
for entry in entries:
|
||||
siren = entry.get("siren")
|
||||
if not siren:
|
||||
continue
|
||||
if entry.get("confidentiality") not in (None, "Public"):
|
||||
total_ignores_confidentiel += 1
|
||||
continue
|
||||
bilan_saisi = entry.get("bilanSaisi")
|
||||
if bilan_saisi:
|
||||
type_bilan = bilan_saisi.get("bilan", {}).get("identite", {}).get("codeTypeBilan")
|
||||
if type_bilan and type_bilan not in TYPE_BILAN_MAPPING:
|
||||
total_types_inconnus += 1
|
||||
unknown_types[type_bilan] = unknown_types.get(type_bilan, 0) + 1
|
||||
parsed = parse_bilan(entry)
|
||||
if parsed is None:
|
||||
continue
|
||||
annee, type_bilan, ca, rn = parsed
|
||||
prio = TYPE_PRIORITY.get(type_bilan, 99)
|
||||
key = (siren, annee)
|
||||
current = best.get(key)
|
||||
if current is None or prio < current[0]:
|
||||
best[key] = (prio, ca, rn)
|
||||
|
||||
for (siren, annee), (_prio, ca, rn) in best.items():
|
||||
upsert_finance(conn, table3, siren, annee, ca, rn)
|
||||
total_rows += 1
|
||||
|
||||
if (i // BATCH_SIZE) % 20 == 0:
|
||||
log(f" ... {i+len(batch)}/{len(sirens)} entreprises traitees, "
|
||||
f"{total_rows} lignes financieres upsertees jusqu'ici")
|
||||
|
||||
log(f"Departement {dep} termine : {total_rows} lignes financieres upsertees dans {table3} "
|
||||
f"({total_ignores_confidentiel} bilans confidentiels ignores, "
|
||||
f"{total_types_inconnus} types de bilan non mappes : {unknown_types})")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--departements", default=None,
|
||||
help="Liste separee par des virgules (defaut : tous ceux de ENT_Departements)")
|
||||
ap.add_argument("--env-file", default=os.path.join(os.path.dirname(__file__), ".env"))
|
||||
args = ap.parse_args()
|
||||
|
||||
env = load_env(args.env_file)
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=env["ENTREPRISES_DB_HOST"], port=int(env.get("ENTREPRISES_DB_PORT", 3306)),
|
||||
user=env["ENTREPRISES_DB_USER"], password=env["ENTREPRISES_DB_PASS"],
|
||||
database=env["ENTREPRISES_DB_NAME"], charset="utf8mb4", autocommit=True,
|
||||
)
|
||||
|
||||
if args.departements:
|
||||
departements = [d.strip() for d in args.departements.split(",") if d.strip()]
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT DEP_Code FROM ENT_Departements ORDER BY DEP_Code")
|
||||
departements = [row[0] for row in cur.fetchall()]
|
||||
|
||||
log(f"Departements a traiter : {departements}")
|
||||
auth = {
|
||||
"token": login(env["INPI_USERNAME"], env["INPI_PASSWORD"]),
|
||||
"username": env["INPI_USERNAME"],
|
||||
"password": env["INPI_PASSWORD"],
|
||||
}
|
||||
log("Connexion INPI reussie")
|
||||
|
||||
try:
|
||||
for dep in departements:
|
||||
backfill_departement(conn, dep, auth)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -115,6 +115,26 @@ NAF_CODES = [
|
||||
'98.20Z', '99.00Z',
|
||||
]
|
||||
|
||||
# Plage de divisions (2 premiers chiffres du code NAF) par section -- permet de
|
||||
# limiter le repli par code NAF precis (3e niveau) aux seuls codes de la section
|
||||
# en cours, au lieu de retester les 732 codes en entier a chaque section plafonnee
|
||||
# (bug corrige le 13/09/2026 : provoquait un blocage de plusieurs heures sur les
|
||||
# grandes villes, ex. Toulouse, ou plusieurs sections restent plafonnees).
|
||||
SECTION_DIVISIONS = {
|
||||
"A": range(1, 4), "B": range(5, 10), "C": range(10, 34), "D": range(35, 36),
|
||||
"E": range(36, 40), "F": range(41, 44), "G": range(45, 48), "H": range(49, 54),
|
||||
"I": range(55, 57), "J": range(58, 64), "K": range(64, 67), "L": range(68, 69),
|
||||
"M": range(69, 76), "N": range(77, 83), "O": range(84, 85), "P": range(85, 86),
|
||||
"Q": range(86, 89), "R": range(90, 94), "S": range(94, 97), "T": range(97, 99),
|
||||
"U": range(99, 100),
|
||||
}
|
||||
|
||||
|
||||
def codes_for_section(section):
|
||||
divisions = SECTION_DIVISIONS[section]
|
||||
return [c for c in NAF_CODES if int(c[:2]) in divisions]
|
||||
|
||||
|
||||
CAP = 10000
|
||||
PAGE_SIZE = 25
|
||||
RATE_DELAY = 0.15 # ~6-7 req/s, sous le quota documente de l'API
|
||||
@@ -128,6 +148,7 @@ CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase1` (
|
||||
`ENT_AdresseSiege` varchar(255) DEFAULT NULL,
|
||||
`ENT_CodePostal` varchar(20) DEFAULT NULL,
|
||||
`ENT_Commune` varchar(100) DEFAULT NULL,
|
||||
`ENT_CodeCommune` char(5) DEFAULT NULL,
|
||||
`ENT_Departement` char(3) DEFAULT NULL,
|
||||
`ENT_Region` char(2) DEFAULT NULL,
|
||||
`ENT_DateCreation` date DEFAULT NULL,
|
||||
@@ -154,8 +175,10 @@ CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase1` (
|
||||
`ENT_DateImport` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`ENT_DateMajInsee` datetime DEFAULT NULL,
|
||||
`ENT_ReponseBruteJson` json DEFAULT NULL,
|
||||
PRIMARY KEY (`ENT_Siren`)
|
||||
PRIMARY KEY (`ENT_Siren`),
|
||||
KEY `idx_commune` (`ENT_CodeCommune`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8
|
||||
"""
|
||||
|
||||
DDL_PHASE2 = """
|
||||
@@ -183,7 +206,7 @@ CREATE TABLE IF NOT EXISTS `Entreprises{dep}Phase3` (
|
||||
DDL_COMMUNES = """
|
||||
CREATE TABLE IF NOT EXISTS `ENT_Communes` (
|
||||
`COM_CodeInsee` char(5) NOT NULL,
|
||||
`COM_CodePostal` varchar(20) DEFAULT NULL,
|
||||
`COM_CodePostal` varchar(100) DEFAULT NULL,
|
||||
`COM_Ville` varchar(100) NOT NULL,
|
||||
`COM_Departement` char(3) NOT NULL,
|
||||
`COM_Latitude` decimal(10,6) DEFAULT NULL,
|
||||
@@ -193,6 +216,25 @@ CREATE TABLE IF NOT EXISTS `ENT_Communes` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
"""
|
||||
|
||||
DDL_DEPARTEMENTS = """
|
||||
CREATE TABLE IF NOT EXISTS `ENT_Departements` (
|
||||
`DEP_Code` char(3) NOT NULL,
|
||||
`DEP_Nom` varchar(100) NOT NULL,
|
||||
`DEP_NbEntreprises` int NOT NULL DEFAULT 0,
|
||||
`DEP_DateExtractionInitiale` datetime DEFAULT NULL,
|
||||
`DEP_DateDerniereSyncFormalites` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`DEP_Code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
"""
|
||||
|
||||
DDL_CONFIG = """
|
||||
CREATE TABLE IF NOT EXISTS `ENT_Config` (
|
||||
`CFG_Cle` varchar(100) NOT NULL,
|
||||
`CFG_Valeur` varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`CFG_Cle`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
"""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
@@ -209,11 +251,51 @@ def http_get_json(url, retries=5):
|
||||
time.sleep(1 + attempt)
|
||||
|
||||
|
||||
# Paris, Lyon et Marseille (PLM) : dans les donnees Sirene, le siege d'une
|
||||
# entreprise de ces trois villes est quasi toujours code au niveau de
|
||||
# l'arrondissement (75101-75120, 69381-69389, 13201-13216), jamais au code
|
||||
# commune englobant (75056, 69123, 13055) -- verifie empiriquement le 14
|
||||
# septembre 2026 sur Marseille : code_commune=13055 ne renvoie qu'une dizaine
|
||||
# de resultats, alors que chaque arrondissement (13201 a 13216) est a lui
|
||||
# seul plafonne ou proche du plafond de 10 000. Le code englobant est donc
|
||||
# remplace par la liste de ses arrondissements avant extraction.
|
||||
COMMUNES_ARRONDISSEMENTS = {"75": "75056", "69": "69123", "13": "13055"}
|
||||
|
||||
|
||||
def fetch_arrondissements_geo(dep):
|
||||
url = "https://geo.api.gouv.fr/communes?" + urllib.parse.urlencode(
|
||||
{"codeDepartement": dep, "type": "arrondissement-municipal",
|
||||
"fields": "code,nom,centre,codesPostaux,departement"}
|
||||
)
|
||||
return http_get_json(url)
|
||||
|
||||
|
||||
def fetch_communes_geo(dep):
|
||||
url = API_GEO.format(dep=dep) + "?" + urllib.parse.urlencode(
|
||||
{"fields": "code,nom,centre,codesPostaux,departement"}
|
||||
)
|
||||
return http_get_json(url)
|
||||
communes = http_get_json(url)
|
||||
code_englobant = COMMUNES_ARRONDISSEMENTS.get(dep)
|
||||
if code_englobant:
|
||||
communes = [c for c in communes if c["code"] != code_englobant]
|
||||
communes.extend(fetch_arrondissements_geo(dep))
|
||||
return communes
|
||||
|
||||
|
||||
def fetch_departement_nom(dep):
|
||||
url = f"https://geo.api.gouv.fr/departements/{dep}?fields=code,nom"
|
||||
data = http_get_json(url)
|
||||
return data.get("nom", dep)
|
||||
|
||||
|
||||
def upsert_departement(conn, dep, nom, nb_entreprises):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO ENT_Departements (DEP_Code, DEP_Nom, DEP_NbEntreprises, DEP_DateExtractionInitiale) "
|
||||
"VALUES (%s, %s, %s, NOW()) "
|
||||
"ON DUPLICATE KEY UPDATE DEP_Nom=VALUES(DEP_Nom), DEP_NbEntreprises=VALUES(DEP_NbEntreprises)",
|
||||
(dep, nom, nb_entreprises),
|
||||
)
|
||||
|
||||
|
||||
NON_DIFFUSIBLE = "[NON-DIFFUSIBLE]"
|
||||
@@ -254,6 +336,7 @@ def map_entreprise(r):
|
||||
"ENT_AdresseSiege": clean(siege.get("adresse")),
|
||||
"ENT_CodePostal": clean(siege.get("code_postal")),
|
||||
"ENT_Commune": clean(siege.get("libelle_commune")),
|
||||
"ENT_CodeCommune": clean(siege.get("commune")),
|
||||
"ENT_Departement": clean(siege.get("departement")),
|
||||
"ENT_Region": clean(siege.get("region")),
|
||||
"ENT_DateCreation": clean(r.get("date_creation")),
|
||||
@@ -329,7 +412,7 @@ def fetch_commune_entreprises(code_commune, seen):
|
||||
# au filtre de section.
|
||||
log(f" commune {code_commune} section {section} encore plafonnee ({sec_total}), "
|
||||
f"repli par code NAF precis")
|
||||
for code in NAF_CODES:
|
||||
for code in codes_for_section(section):
|
||||
code_params = dict(base, activite_principale=code)
|
||||
code_total = probe_total(code_params)
|
||||
if code_total == 0:
|
||||
@@ -383,6 +466,8 @@ def ensure_tables(conn, dep):
|
||||
cur.execute(DDL_PHASE2.format(dep=dep))
|
||||
cur.execute(DDL_PHASE3.format(dep=dep))
|
||||
cur.execute(DDL_COMMUNES)
|
||||
cur.execute(DDL_DEPARTEMENTS)
|
||||
cur.execute(DDL_CONFIG)
|
||||
|
||||
|
||||
def ensure_communes(conn, dep, communes_geo):
|
||||
@@ -431,8 +516,16 @@ def run_department(conn, dep):
|
||||
total_finances += 1
|
||||
if results:
|
||||
log(f" {c['nom']} ({c['code']}) : {len(results)} entreprises")
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT COUNT(*) FROM `{table1}`")
|
||||
(nb_total,) = cur.fetchone()
|
||||
nom_dep = fetch_departement_nom(dep)
|
||||
upsert_departement(conn, dep, nom_dep, nb_total)
|
||||
|
||||
log(f"Departement {dep} termine : {total_upserts} entreprises upsertees dans {table1}, "
|
||||
f"{total_finances} lignes financieres upsertees dans {table3}")
|
||||
f"{total_finances} lignes financieres upsertees dans {table3} (total {nb_total} -- "
|
||||
f"ENT_Departements mise a jour)")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in New Issue
Block a user