mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 18:17:09 +00:00
V1.23.28 Mise à jour de la BDD des entreprises et stratégies
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user