From ca79b8f8e6ba818907c164ef720c1e97806d88f2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 17 Jun 2026 08:38:23 +0200 Subject: [PATCH] chore(migration) : outils d'extraction des tiers Mixgraine (WIP) Boite a outils de migration des tiers (clients / fournisseurs / prestataires) depuis l'ancien CRM Mixgraine vers Starseed : - extract_mixgraine.py : extraction + normalisation via l'API Mixgraine (cache disque reprenable, debit ~1 req/s, backoff 429/5xx) -> JSON format Starseed - build_tiers_xlsx.py : Excel de relecture (1 onglet par type + Synthese, colonne 'Site manquant' filtrable) - run.sh : enchaine extraction + Excel - README.md : prerequis, recuperation du token, lancement - mixgraine-migration-analysis.md : analyse + mapping des champs Mixgraine -> Starseed WIP : les commandes d'import Symfony cote Starseed (seed referentiels/sites, import Client/Supplier/Provider, 2e passe distributeur/courtier) restent a faire. Le dossier de sortie mixgraine-export/ (IBAN/BIC + PII reelles) est volontairement .gitignore : reproductible localement via MIXGRAINE_JWT. --- docs/migration/.gitignore | 4 + docs/migration/README.md | 80 +++ docs/migration/build_tiers_xlsx.py | 201 +++++++ docs/migration/extract_mixgraine.py | 496 ++++++++++++++++++ .../migration/mixgraine-migration-analysis.md | 306 +++++++++++ docs/migration/run.sh | 25 + 6 files changed, 1112 insertions(+) create mode 100644 docs/migration/.gitignore create mode 100644 docs/migration/README.md create mode 100644 docs/migration/build_tiers_xlsx.py create mode 100644 docs/migration/extract_mixgraine.py create mode 100644 docs/migration/mixgraine-migration-analysis.md create mode 100755 docs/migration/run.sh diff --git a/docs/migration/.gitignore b/docs/migration/.gitignore new file mode 100644 index 0000000..49c31c1 --- /dev/null +++ b/docs/migration/.gitignore @@ -0,0 +1,4 @@ +# Donnees extraites de Mixgraine : raisons sociales, contacts, adresses, +# n TVA et coordonnees bancaires (IBAN/BIC) reelles. NE JAMAIS committer. +# Reproductible localement via `MIXGRAINE_JWT=... ./run.sh`. +mixgraine-export/ diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 0000000..5805a60 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,80 @@ +# Migration Mixgraine → Starseed — Outils + +Boîte à outils pour migrer les tiers (clients / fournisseurs / prestataires) depuis +le CRM Mixgraine vers Starseed. Tout est autonome : 2 scripts Python + ce README. + +| Fichier | Rôle | +|---|---| +| `mixgraine-migration-analysis.md` | Rapport d'analyse + mapping des champs (à lire en premier) | +| `extract_mixgraine.py` | Récupère les tiers depuis l'API Mixgraine et les normalise en JSON | +| `build_tiers_xlsx.py` | Produit un Excel avec **un onglet par type** + filtre « Site manquant » | +| `run.sh` | Enchaîne les deux scripts | + +--- + +## Prérequis + +- **Python 3** (déjà présent sur un poste Linux/Mac) — vérifier : `python3 --version` +- **openpyxl** (pour les Excel) : `pip install openpyxl` (ou `pip install --break-system-packages openpyxl`) +- Un **token Mixgraine** (JWT) valide — voir ci-dessous. + +### Récupérer le token Mixgraine + +1. Ouvrir `https://liot.mixsuite.fr` dans Chrome, connecté. +2. Ouvrir les outils dev (F12) → onglet **Network**. +3. Cliquer sur n'importe quelle liste (clients…) pour déclencher un appel `api/customer`. +4. Cliquer sur la requête → **Headers** → copier la valeur après `authorization: Bearer ` (la longue chaîne `eyJ0eXAi...`). + +> Le token expire au bout de quelques jours. S'il est expiré, en récupérer un nouveau de la même façon. **Ne jamais le committer dans git.** + +--- + +## Lancement + +### Méthode simple (script tout-en-un) + +```bash +cd docs/migration +export MIXGRAINE_JWT="eyJ0eXAi...colle-ton-token-ici..." +./run.sh +``` + +### Ou étape par étape + +```bash +cd docs/migration +export MIXGRAINE_JWT="eyJ0eXAi..." + +# 1) Extraction + normalisation (lent : ~1 requête/seconde, soyez patient) +python3 extract_mixgraine.py +# -> écrit dans ./mixgraine-export/ : clients.json, suppliers.json, +# providers.json, referentials.json, extraction-report.txt +# Test rapide sur 20 tiers : python3 extract_mixgraine.py --limit-ids 20 + +# 2) Génération de l'Excel de relecture (un onglet par type) +python3 build_tiers_xlsx.py --in mixgraine-export +# -> mixgraine-export/mixgraine-tiers.xlsx +# onglets Clients / Fournisseurs / Prestataires + Synthèse, +# colonne « Site manquant » filtrable, lignes à problème surlignées +``` + +--- + +## Ce que tu obtiens + +Tout est écrit dans `docs/migration/mixgraine-export/` : + +- **`*.json`** — données normalisées au format Starseed (utilisées plus tard par les commandes d'import). +- **`mixgraine-tiers.xlsx`** — un onglet par type (**Clients / Fournisseurs / Prestataires**) + un onglet **Synthèse**. Chaque onglet liste toutes les données (une ligne par adresse), avec une colonne **Site manquant** (OUI/vide) et une colonne **Problèmes**. Le **filtre automatique** est activé : clique l'entonnoir de la colonne « Site manquant » → coche `OUI` pour isoler les adresses sans site. +- **`extraction-report.txt`** — compteurs + avertissements. +- **`cache/`** — réponses brutes par tiers (permet de relancer sans tout refetch). + +**Workflow conseillé** : ouvrir `mixgraine-tiers.xlsx`, filtrer « Site manquant = OUI » dans chaque onglet, corriger la donnée **à la source dans Mixgraine** (re-cocher les organisations/sites, compléter les emails de facturation), puis relancer (le cache accélère). + +--- + +## Notes + +- L'extraction est **reprenable** : si elle s'interrompt, relance-la, elle repart du cache. +- Le débit est volontairement lent (`--delay 1.0` par défaut) pour ne pas saturer le serveur. Pour aller plus vite (à tes risques) : `--delay 0.3`. +- Le dossier `mixgraine-export/` n'a pas vocation à être committé (données + token-sensible). Pense à l'ajouter au `.gitignore` si besoin. diff --git a/docs/migration/build_tiers_xlsx.py b/docs/migration/build_tiers_xlsx.py new file mode 100644 index 0000000..2188158 --- /dev/null +++ b/docs/migration/build_tiers_xlsx.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Construit UN classeur Excel de relecture a partir des JSON produits par +extract_mixgraine.py, avec UN ONGLET PAR TYPE : + + - Clients + - Fournisseurs + - Prestataires + - Synthese (compteurs) + +Chaque onglet liste TOUTES les donnees, une ligne par adresse (les colonnes du +tiers sont repetees). Une colonne « Site manquant » (OUI / vide) + le filtre +automatique Excel permettent de trier en un clic les adresses sans site +(obligatoire cote Starseed — RG-1.10 / 2.06 / 3.03). Les lignes a probleme sont +surlignees en rouge clair. + +Usage : + pip install openpyxl + python3 build_tiers_xlsx.py --in mixgraine-export +""" + +import argparse +import json +import os +import sys +from collections import Counter + +try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from openpyxl.utils import get_column_letter +except ImportError: + sys.exit("Dependance manquante : pip install openpyxl") + + +# Compatibilite : les JSON deja generes portent la cle "lauttreeId" ; +# les extractions ulterieures porteront "mixgraineId". +def tid(t): + return t.get("mixgraineId") or t.get("lauttreeId") + + +def address_problems(addr, target): + pb = [] + if not addr.get("sites"): + pb.append("aucun site") + if not addr.get("postalCode"): + pb.append("code postal absent/invalide") + if not addr.get("city"): + pb.append("ville absente") + if not addr.get("street"): + pb.append("rue absente") + if target == "Client" and addr.get("isBilling") and not addr.get("billingEmail"): + pb.append("facturation sans email") + return pb + + +def tiers_problems(t, target): + pb = [] + if not t.get("companyName"): + pb.append("nom absent") + if t.get("paymentType") == "VIREMENT" and not t.get("bank"): + pb.append("VIREMENT sans banque") + if t.get("paymentType") == "LCR" and not t.get("ribs"): + pb.append("LCR sans RIB") + if target == "Provider" and not t.get("sites"): + pb.append("prestataire sans site") + if t.get("categories") == ["A QUALIFIER"]: + pb.append("categorie a qualifier") + return pb + + +# Colonnes communes (tiers). La colonne « Sites prestataire » n'existe QUE dans +# l'onglet Prestataires (le site y est porte par le tiers, RG-3.03). Pour Clients +# et Fournisseurs, le site est uniquement au niveau adresse (« Sites adresse »). +TIERS_COLS = [ + "Réf.", "Société", "Catégories", "Mode paiement", "Banque", "N° TVA", + "N° compte", "Distributeur", "Courtier", "Nb contacts", "Nb RIB", +] +ADDR_COLS = [ + "Rue", "Complément", "Code postal", "Ville", "Pays", "Sites adresse", + "Facturation", "Email facturation", "Site manquant", "Problèmes tiers", +] + + +def headers_for(target): + cols = list(TIERS_COLS) + if target == "Provider": + cols.append("Sites prestataire") + return cols + ADDR_COLS + + +HEADER_FILL = PatternFill("solid", fgColor="222783") +HEADER_FONT = Font(bold=True, color="FFFFFF") +BAD_FILL = PatternFill("solid", fgColor="FCE4E4") # rouge clair (probleme) +WARN_FILL = PatternFill("solid", fgColor="FFF4D6") # orange clair (site manquant) + + +def row_for(t, target, addr): + pbt = " ; ".join(tiers_problems(t, target)) + base = [ + tid(t), t.get("companyName"), ", ".join(t.get("categories") or []), + t.get("paymentType") or "", t.get("bank") or "", t.get("nTva") or "", + t.get("accountNumber") or "", t.get("distributorName") or "", + t.get("brokerName") or "", len(t.get("contacts", [])), len(t.get("ribs", [])), + ] + if target == "Provider": + base.append(", ".join(t.get("sites") or [])) + if addr is None: + return base + ["", "", "", "", "", "", "", "", "(pas d'adresse)", pbt] + site_missing = "OUI" if not addr.get("sites") else "" + return base + [ + addr.get("street") or "", addr.get("streetComplement") or "", + addr.get("postalCode") or "", addr.get("city") or "", + addr.get("country") or "", ", ".join(addr.get("sites") or []), + "oui" if addr.get("isBilling") else "", addr.get("billingEmail") or "", + site_missing, pbt, + ] + + +def build_sheet(wb, title, data, target): + headers = headers_for(target) + site_col = headers.index("Site manquant") + 1 + prob_col = len(headers) + ws = wb.create_sheet(title=title) + ws.append(headers) + for c in range(1, len(headers) + 1): + cell = ws.cell(row=1, column=c) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(vertical="center") + rows = [] + for t in data: + for a in (t.get("addresses") or [None]): + rows.append((row_for(t, target, a), address_problems(a, target) if a else ["aucune adresse"])) + for values, pbs in rows: + ws.append(values) + r = ws.max_row + if values[site_col - 1] == "OUI": + ws.cell(row=r, column=site_col).fill = WARN_FILL + if pbs or values[-1]: + ws.cell(row=r, column=prob_col).fill = BAD_FILL + # filtre + gel des en-tetes + largeurs + ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{ws.max_row}" + ws.freeze_panes = "A2" + for c, h in enumerate(headers, 1): + sample = [len(str(h))] + [len(str(v[c - 1])) for v, _ in rows[:300]] + ws.column_dimensions[get_column_letter(c)].width = min(max(max(sample) + 2, 10), 50) + return len(rows) + + +def main(): + ap = argparse.ArgumentParser(description="Excel par type (Clients/Fournisseurs/Prestataires) + filtre site manquant") + ap.add_argument("--in", dest="indir", default="mixgraine-export", help="dossier des JSON") + ap.add_argument("--out", default=None, help="chemin du xlsx (defaut: /mixgraine-tiers.xlsx)") + args = ap.parse_args() + + def load(name): + path = os.path.join(args.indir, name) + return json.load(open(path, encoding="utf-8")) if os.path.exists(path) else [] + + sources = [ + ("Clients", load("clients.json"), "Client"), + ("Fournisseurs", load("suppliers.json"), "Supplier"), + ("Prestataires", load("providers.json"), "Provider"), + ] + + wb = Workbook() + wb.remove(wb.active) + summary = [] + for title, data, target in sources: + n_rows = build_sheet(wb, title, data, target) + n_tiers = len(data) + n_addr_missing = sum(1 for t in data for a in (t.get("addresses") or []) if not a.get("sites")) + n_nocat = sum(1 for t in data if t.get("categories") == ["A QUALIFIER"]) + summary.append((title, n_tiers, n_rows, n_addr_missing, n_nocat)) + + # onglet Synthese + ws = wb.create_sheet(title="Synthèse") + sh = ["Type", "Tiers", "Lignes (adresses)", "Adresses sans site", "Sans catégorie"] + ws.append(sh) + for c in range(1, len(sh) + 1): + ws.cell(row=1, column=c).fill = HEADER_FILL + ws.cell(row=1, column=c).font = HEADER_FONT + for r in summary: + ws.append(list(r)) + for c in range(1, len(sh) + 1): + ws.column_dimensions[get_column_letter(c)].width = 20 + # place la synthese en premier + wb.move_sheet("Synthèse", -(len(wb.sheetnames) - 1)) + + out = args.out or os.path.join(args.indir, "mixgraine-tiers.xlsx") + wb.save(out) + print(f"Ecrit : {out}") + print(f"{'Type':14}{'Tiers':>7}{'Lignes':>8}{'AdrSansSite':>13}{'SansCat':>9}") + for title, n_tiers, n_rows, n_miss, n_nocat in summary: + print(f"{title:14}{n_tiers:7}{n_rows:8}{n_miss:13}{n_nocat:9}") + + +if __name__ == "__main__": + main() diff --git a/docs/migration/extract_mixgraine.py b/docs/migration/extract_mixgraine.py new file mode 100644 index 0000000..94316e5 --- /dev/null +++ b/docs/migration/extract_mixgraine.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Extraction + normalisation des tiers (clients / fournisseurs) depuis le CRM +Mixgraine (https://liot.mixsuite.fr) vers le format des entites Client / Supplier +de Starseed. + +Principe : + 1. Pagine GET /api/customer/?...&page=N pour collecter tous les id. + 2. Pour chaque id, recupere la fiche COMPLETE via + PUT /api/customer/{id} body {"__data": true} + (c'est l'appel que fait le front pour PRECHARGER le formulaire d'edition : + il NE MODIFIE RIEN, il renvoie le schema + les valeurs courantes). + 3. Resout les selects (paymentType, banque, pays, distributeur, sites...) via + le schema renvoye, puis normalise chaque tiers au format Starseed. + 4. Ecrit clients.json, suppliers.json, referentials.json + un rapport. + +Caracteristiques : + - Zero dependance (stdlib uniquement). + - Cache disque par id (reprise apres interruption, pas de refetch). + - Debit volontairement lent (--delay) pour ne pas saturer le serveur. + - Backoff automatique sur erreur reseau / 429 / 5xx. + +Usage : + export MIXGRAINE_JWT="eyJ0eXAi..." # ton token Bearer (NE PAS committer) + python3 extract_mixgraine.py # extraction complete + python3 extract_mixgraine.py --delay 1.0 # encore plus doux + python3 extract_mixgraine.py --limit-ids 20 # test rapide sur 20 tiers + +Le JWT est un secret de session : passe-le par variable d'environnement, +ne l'ecris jamais en dur ici. +""" + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +BASE = os.environ.get("MIXGRAINE_BASE", "https://liot.mixsuite.fr") +JWT = os.environ.get("MIXGRAINE_JWT") or os.environ.get("LAUTTREE_JWT", "") + +# --- Tables de correspondance Mixgraine -> codes referentiels Starseed --------- + +TVA_MODE = { + "France (ventes)": "FRANCE_VENTES", + "Export (ventes)": "EXPORT_VENTES", + "Intracom (ventes)": "INTRACOM_VENTES", + "France (achats)": "FRANCE_VENTES", # pas de mode "achats" au seed -> a trancher +} +PAYMENT_DELAY = { + "15 jours": "J15", + "20 jours": "J20", # absent du seed Starseed -> a creer + "30 jours": "J30", + "A reception": "A_RECEPTION", + "A réception": "A_RECEPTION", +} +PAYMENT_TYPE = { + "LCR non soumise": "NON_SOUMISE", # pas LCR : on n'a pas toujours de RIB (RG-1.13) + "Virement": "VIREMENT", + "Cheque": "CHEQUE", + "Chèque": "CHEQUE", +} +BANK = { + "CIC": "CIC", + "SOCIETE GENERALE": "SG", + "CREDIT AGRICOLE": "CA", +} + +CIVILITES = ("Mme", "Mlle", "Mle", "M.", "Mr", "M") # ordre : plus long d'abord + + +# --- Petites fonctions utilitaires ------------------------------------------- + +def http(method, path, body=None, tries=5): + """Appel HTTP avec retry/backoff. Renvoie le JSON decode.""" + url = BASE + path + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = { + "Accept": "application/json, text/plain, */*", + "Authorization": "Bearer " + JWT, + } + if data is not None: + headers["Content-Type"] = "application/json" + delay = 2.0 + for attempt in range(1, tries + 1): + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + if e.code in (429, 500, 502, 503, 504) and attempt < tries: + print(f" ! HTTP {e.code} sur {path} -> retry dans {delay:.0f}s", file=sys.stderr) + time.sleep(delay) + delay *= 2 + continue + raise + except (urllib.error.URLError, TimeoutError) as e: + if attempt < tries: + print(f" ! reseau ({e}) -> retry dans {delay:.0f}s", file=sys.stderr) + time.sleep(delay) + delay *= 2 + continue + raise + raise RuntimeError("echec apres %d tentatives : %s" % (tries, path)) + + +def choices_map(field): + """Construit {value: label} depuis un champ select du schema.""" + out = {} + try: + for c in field["type"]["choices"]: + out[c["value"]] = c["label"] + except (KeyError, TypeError): + pass + return out + + +def first_id(val): + """Mixgraine renvoie soit un id, soit [] (vide), soit [{id:..}].""" + if isinstance(val, list): + return val[0]["id"] if val and isinstance(val[0], dict) else None + return val if val not in ("", None) else None + + +def parse_contact_name(name): + """'M.ROBERT Florian' -> (lastName='ROBERT', firstName='Florian').""" + if not name: + return None, None + s = name.strip() + for civ in CIVILITES: + if s.upper().startswith(civ.upper()): + s = s[len(civ):].strip(" .") + break + parts = [p for p in s.split() if p] + if not parts: + return None, None + if len(parts) == 1: + return parts[0], None # un seul mot -> nom de famille + return parts[0], " ".join(parts[1:]) # 1er = nom, reste = prenom + + +def clean_phone(p): + """Tronque/nettoie pour tenir dans 20 caracteres (limite Starseed).""" + if not p: + return None, None + raw = str(p).strip() + # garde le 1er numero si plusieurs ('... (direct) / ... (standard)') + candidate = re.split(r"[/(]", raw)[0].strip() + cleaned = candidate if candidate else raw + flag = None + if len(cleaned) > 20: + flag = f"tel tronque ({raw!r})" + cleaned = cleaned[:20].strip() + return cleaned, flag + + +POSTCODE_RE = re.compile(r"^\d{4,5}$") + + +def clean_postcode(p): + if not p: + return None, None + s = str(p).strip() + if POSTCODE_RE.match(s): + return s, None + return None, f"code postal invalide ({p!r})" + + +# --- Normalisation d'un tiers ------------------------------------------------- + +def normalize(record, warnings): + """record = reponse PUT {__data:true}. Renvoie un dict normalise Starseed.""" + fields = record.get("fields", {}) + d = record.get("__data", {}) + details = record.get("details", {}) + geo = details.get("geo", {}) or {} + + tid = d.get("id") + name = d.get("name") or d.get("reference") + + # --- resolveurs depuis le schema de CE record --- + liab = choices_map(fields.get("liability", {})) + pdelay = choices_map(fields.get("paymentDelay", {})) + ptype = choices_map(fields.get("paymentType", {})) + bank = choices_map(fields.get("accountingBank", {})) + distrib = choices_map(fields.get("distributor", {})) + courtier = choices_map(fields.get("courtier", {})) + cats = choices_map(fields.get("categories", {})) + addr_fields = fields.get("addresses", {}).get("type", {}).get("fields", {}) + country_map = choices_map(addr_fields.get("country", {})) + addr_cats = choices_map(addr_fields.get("categories", {})) + carrier_map = choices_map(addr_fields.get("carrierType", {})) + # libelles des sites (organisations) + site_labels = { + "organization_1": addr_fields.get("organization_1", {}).get("label"), + "organization_2": addr_fields.get("organization_2", {}).get("label"), + "organization_3": addr_fields.get("organization_3", {}).get("label"), + } + + def map_ref(table, label, what): + if label is None: + return None + code = table.get(label) + if code is None: + warnings.append(f"tiers {tid} ({name}): {what} non mappe : {label!r}") + return code + + # --- referentiels comptables --- + tva = map_ref(TVA_MODE, liab.get(first_id(d.get("liability"))), "tvaMode") + delay = map_ref(PAYMENT_DELAY, pdelay.get(first_id(d.get("paymentDelay"))), "paymentDelay") + pay = map_ref(PAYMENT_TYPE, ptype.get(first_id(d.get("paymentType"))), "paymentType") + bnk = map_ref(BANK, bank.get(first_id(d.get("accountingBank"))), "bank") + + # --- categories tiers --- + categories = [] + for c in d.get("categories", []) or []: + lbl = cats.get(c.get("id")) + if lbl: + categories.append(lbl) + if not categories: + categories = ["A QUALIFIER"] # contrainte min 1 cote Starseed + warnings.append(f"tiers {tid} ({name}): aucune categorie -> 'A QUALIFIER'") + + # --- contacts --- + contacts = [] + contact_phones = set() + for c in d.get("contacts", []) or []: + last, first = parse_contact_name(c.get("name")) + phone, f1 = clean_phone(c.get("phone")) + mobile, f2 = clean_phone(c.get("mobile")) + if f1: + warnings.append(f"tiers {tid} ({name}): {f1}") + if f2: + warnings.append(f"tiers {tid} ({name}): {f2}") + if not last and not first: + last = "Standard" # RG-1.05/2.04 : au moins un nom + for ph in (phone, mobile): + if ph: + contact_phones.add(re.sub(r"\D", "", ph)) + contacts.append({ + "mixgraineId": c.get("id"), + "lastName": last, + "firstName": first, + "jobTitle": c.get("function"), + "email": (c.get("email") or None), + "phonePrimary": phone, + "phoneSecondary": mobile, + }) + + # tel porte par l'objet de base -> dans la liste de contacts (jamais a la racine) + base_phone, fb = clean_phone(d.get("phone")) + if fb: + warnings.append(f"tiers {tid} ({name}): {fb}") + if base_phone and re.sub(r"\D", "", base_phone) not in contact_phones: + if contacts: + # complete le 1er contact sans tel secondaire + for c in contacts: + if not c["phoneSecondary"]: + c["phoneSecondary"] = base_phone + break + else: + contacts[0]["phoneSecondary"] = base_phone + else: + contacts.append({ + "mixgraineId": None, "lastName": "Standard", "firstName": None, + "jobTitle": None, "email": None, + "phonePrimary": base_phone, "phoneSecondary": None, + }) + + # --- emails de facturation (mails[] avec invoice=true) --- + billing_mails = [m["mail"] for m in (d.get("mails") or []) if m.get("invoice") and m.get("mail")] + + # --- adresses --- + addresses = [] + for a in d.get("addresses", []) or []: + pc, fp = clean_postcode(a.get("postcode")) + if fp: + warnings.append(f"tiers {tid} ({name}): {fp}") + # sites depuis les booleens organization_n + sites = [site_labels[k] for k in ("organization_1", "organization_2", "organization_3") + if a.get(k) and site_labels[k]] + # categories d'adresse + acats = [addr_cats.get(c.get("id")) for c in (a.get("categories") or []) if addr_cats.get(c.get("id"))] + # type d'adresse fournisseur (Rendu/Depart) depuis carrierType + carrier = carrier_map.get(a.get("carrierType")) + supplier_addr_type = {"Rendu": "RENDU", "Départ": "DEPART", "Depart": "DEPART"}.get(carrier) + latlng = geo.get(str(a.get("id"))) or geo.get(a.get("id")) + lat, lng = (latlng.split(",") + [None, None])[:2] if isinstance(latlng, str) else (None, None) + addresses.append({ + "mixgraineId": a.get("id"), + "street": a.get("street1"), + "streetComplement": a.get("street2"), + "postalCode": pc, + "city": a.get("city"), + "country": country_map.get(a.get("country"), "France"), + # flags client + "isBilling": bool(a.get("billing")), + "isDelivery": bool(a.get("sales")), + "isProspect": bool(a.get("salesTrip")), + # type fournisseur + "supplierAddressType": supplier_addr_type or "PROSPECT", + "bennes": a.get("benneCount"), + "sites": sites, + "categories": acats, + "billingEmail": (billing_mails[0] if (a.get("billing") and billing_mails) else None), + "contactMixgraineIds": [c.get("id") for c in (a.get("contacts") or [])], + "lat": lat, "lng": lng, # conserve pour info (pas de cible Starseed) + }) + + # --- RIB (banks[]) --- + ribs = [{ + "label": b.get("label") or "Compte principal", + "iban": b.get("iban"), + "bic": b.get("bic"), + } for b in (d.get("banks") or []) if b.get("iban")] + + return { + "mixgraineId": tid, + "companyName": name, + "isCustomer": bool(d.get("customer")), + "isSupplier": bool(d.get("supplier")), + "accountNumber": d.get("billingAccount") or None, + "nTva": d.get("vatNumber") or None, + "tvaMode": tva, + "paymentDelay": delay, + "paymentType": pay, + "bank": bnk, + "distributorName": distrib.get(first_id(d.get("distributor"))), + "brokerName": courtier.get(first_id(d.get("courtier"))), + "categories": categories, + "contacts": contacts, + "addresses": addresses, + "ribs": ribs, + } + + +# --- Recuperation de la liste des id ----------------------------------------- + +def fetch_ids_for(filters, limit, delay): + """Pagine /api/customer/ pour un jeu de filtres donne et renvoie la liste d'id.""" + fields = urllib.parse.quote('["name"]') + fstr = urllib.parse.quote(json.dumps(filters)) if filters else "" + ids, page, count = [], 0, None + while True: + path = f"/api/customer/?fields={fields}&limit={limit}&order=name&page={page}" + if fstr: + path += f"&filters={fstr}" + resp = http("GET", path) + if count is None: + count = resp.get("count", 0) + batch = resp.get("data", []) + if not batch: + break + ids.extend(r["id"] for r in batch) + page += 1 + if count and len(ids) >= count: + break + time.sleep(delay) + return ids + + +def fetch_all_ids(limit, delay): + """Collecte les id par groupe (client / fournisseur / prestataire) + union. + + On s'appuie sur l'APPARTENANCE aux listes filtrees pour classer chaque tiers, + plus fiable que les flags parfois absents du formulaire __data. + """ + print(" - clients (customer=true)") + customer_ids = set(fetch_ids_for({"customer": True}, limit, delay)) + print(f" {len(customer_ids)}") + print(" - fournisseurs (supplier=true)") + supplier_ids = set(fetch_ids_for({"supplier": True}, limit, delay)) + print(f" {len(supplier_ids)}") + print(" - prestataires (prestataire=true) -> ranges en fournisseurs") + prestataire_ids = set(fetch_ids_for({"prestataire": True}, limit, delay)) + print(f" {len(prestataire_ids)}") + + all_ids = sorted(customer_ids | supplier_ids | prestataire_ids) + print(f" total tiers distincts : {len(all_ids)}") + return all_ids, customer_ids, supplier_ids, prestataire_ids + + +# --- Main --------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="Extraction Mixgraine -> format Starseed") + ap.add_argument("--out", default="mixgraine-export", help="dossier de sortie") + ap.add_argument("--delay", type=float, default=1.0, help="pause (s) entre chaque fiche (defaut 1 req/s)") + ap.add_argument("--limit", type=int, default=200, help="taille de page pour la liste") + ap.add_argument("--limit-ids", type=int, default=0, help="ne traiter que N tiers (test)") + args = ap.parse_args() + + if not JWT: + sys.exit("ERREUR : export MIXGRAINE_JWT='' avant de lancer.") + + cache_dir = os.path.join(args.out, "cache") + os.makedirs(cache_dir, exist_ok=True) + + print("== Etape 1 : liste des id (par groupe) ==") + ids, customer_ids, supplier_ids, prestataire_ids = fetch_all_ids(args.limit, args.delay) + if args.limit_ids: + ids = ids[:args.limit_ids] + print(f"{len(ids)} tiers a recuperer.\n") + + print("== Etape 2 : fiches detaillees (lent, soyez patient) ==") + raw_by_id = {} + for n, tid in enumerate(ids, 1): + cache_file = os.path.join(cache_dir, f"{tid}.json") + if os.path.exists(cache_file): + with open(cache_file, encoding="utf-8") as f: + raw_by_id[tid] = json.load(f) + continue + rec = http("PUT", f"/api/customer/{tid}", body={"__data": True}) + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(rec, f, ensure_ascii=False) + raw_by_id[tid] = rec + if n % 25 == 0 or n == len(ids): + print(f" {n}/{len(ids)} fiches") + time.sleep(args.delay) + + print("\n== Etape 3 : normalisation ==") + warnings = [] + clients, suppliers, providers = [], [], [] + cat_set, site_set = set(), set() + for tid in ids: + norm = normalize(raw_by_id[tid], warnings) + # classification par APPARTENANCE aux listes filtrees (source fiable) : + # customer -> Client (module Commercial) + # supplier -> Supplier (module Commercial) + # prestataire -> Provider (module Technique) — entite dediee, PAS un Supplier + is_customer = tid in customer_ids or norm["isCustomer"] + is_supplier = tid in supplier_ids or norm["isSupplier"] + is_prestataire = tid in prestataire_ids + norm["isCustomer"] = is_customer + norm["isSupplier"] = is_supplier + norm["isPrestataire"] = is_prestataire + # Provider porte les sites DIRECTEMENT (RG-3.03) : on agrege les sites des adresses. + norm["sites"] = sorted({s for a in norm["addresses"] for s in a["sites"]}) + cat_set.update(norm["categories"]) + for a in norm["addresses"]: + site_set.update(a["sites"]) + cat_set.update(a["categories"]) + # un tiers peut cumuler plusieurs roles -> cree dans chaque table concernee + if is_customer: + clients.append(norm) + if is_supplier: + suppliers.append(norm) + if is_prestataire: + providers.append(norm) + if not (is_customer or is_supplier or is_prestataire): + clients.append(norm) # filet de securite : client par defaut + warnings.append(f"tiers {tid} ({norm['companyName']}): aucun flag -> client par defaut") + + referentials = { + "categories": sorted(cat_set), + "sites": sorted(site_set), + "tvaModes": sorted(set(TVA_MODE.values())), + "paymentDelays": sorted(set(PAYMENT_DELAY.values())), + "paymentTypes": sorted(set(PAYMENT_TYPE.values())), + "banks": sorted(set(BANK.values())), + } + + def dump(fname, obj): + with open(os.path.join(args.out, fname), "w", encoding="utf-8") as f: + json.dump(obj, f, ensure_ascii=False, indent=2) + + dump("clients.json", clients) + dump("suppliers.json", suppliers) + dump("providers.json", providers) + dump("referentials.json", referentials) + with open(os.path.join(args.out, "extraction-report.txt"), "w", encoding="utf-8") as f: + f.write(f"Tiers traites : {len(ids)}\n") + f.write(f"Clients : {len(clients)}\n") + f.write(f"Fournisseurs : {len(suppliers)}\n") + f.write(f"Prestataires : {len(providers)}\n") + f.write(f"Categories uniques : {len(referentials['categories'])}\n") + f.write(f"Sites uniques : {referentials['sites']}\n") + f.write(f"Avertissements : {len(warnings)}\n\n") + f.write("\n".join(warnings)) + + print(f"\nTermine.") + print(f" clients.json : {len(clients)}") + print(f" suppliers.json : {len(suppliers)}") + print(f" providers.json : {len(providers)} (prestataires)") + print(f" referentials.json : {len(referentials['categories'])} categories, sites {referentials['sites']}") + print(f" avertissements : {len(warnings)} (voir extraction-report.txt)") + print(f"Sortie dans : {os.path.abspath(args.out)}") + + +if __name__ == "__main__": + main() diff --git a/docs/migration/mixgraine-migration-analysis.md b/docs/migration/mixgraine-migration-analysis.md new file mode 100644 index 0000000..aa150af --- /dev/null +++ b/docs/migration/mixgraine-migration-analysis.md @@ -0,0 +1,306 @@ +# Migration Mixgraine → Starseed — Analyse des tiers (clients / fournisseurs) + +> Sources analysées (exports Mixgraine triés par groupe) : +> - `client.json.json` — **1023 clients** (`customer=true`) +> - `fournisseur.json` — **306 fournisseurs** (`supplier=true`) +> - `fournisseur-client.json` — **106 tiers mixtes** (`customer=true` ET `supplier=true`) +> +> Cible : module `Commercial` de Starseed — entités `Client` / `Supplier` et leurs sous-entités `*Contact`, `*Address`, `*Rib`. +> Date d'analyse : 2026-06-16. (Remplace l'analyse initiale faite sur `customer.json`, 737 tiers, où la classification était absente.) +> +> 🔑 **Source de migration recommandée : l'endpoint unitaire, pas l'export en masse.** +> Les exports CSV/JSON en masse sont **incomplets** : ni RIB/IBAN, ni N° TVA, ni banque de virement, ni rattachement aux sites. Toutes ces données existent dans l'endpoint détail `GET /api/customer/{id}` et surtout dans `PUT /api/customer/{id}` avec `{"__data":true}` (renvoie le **schéma de formulaire complet + les valeurs**). Procédure : paginer `GET /api/customer/?...&limit=...&page=N` pour récupérer les `id`, puis appeler l'endpoint détail pour chaque id. Cf. § 9. + +--- + +## 1. Synthèse + +L'export est désormais **trié par groupe** : la distinction client / fournisseur est nette, ce qui lève le principal point bloquant de l'analyse précédente. **1435 tiers distincts** (aucun recoupement d'id entre les trois fichiers). + +| Groupe source | Tiers | Devient dans Starseed | +|---|---|---| +| `client.json.json` (`customer=true`) | 1023 | `Client` | +| `fournisseur.json` (`supplier=true`) | 306 | `Supplier` | +| `fournisseur-client.json` (`customer=true` + `supplier=true`) | 106 | **`Client` ET `Supplier`** (cf. § 4) | +| **prestataires** (`prestataire=true`, vue séparée Mixgraine) | à extraire via l'API (filtre `prestataire:true`) | **`Provider`** (module Technique, M3) | + +> ✅ Les **prestataires** sont un 4e type de tiers, géré dans une vue dédiée de Mixgraine mais sur le **même endpoint** `/api/customer/` (filtre `{"prestataire":true}`). Côté Starseed, ils ont une **entité dédiée** : `Provider` (module **Technique**, `src/Module/Technique/Domain/Entity/Provider.php`), jumelle du `Supplier`, avec `ProviderContact` / `ProviderAddress` / `ProviderRib`. **Ils ne deviennent donc PAS des `Supplier`.** Particularités vs Supplier : pas d'onglet Information (entité minimale nom + comptabilité) et les **sites sont rattachés directement au prestataire** (M2M `provider_site`, min 1, RG-3.03) — le script agrège donc les sites des adresses au niveau du prestataire. Catégories de type **PRESTATAIRE** exigées (RG-3.09). Le script collecte les prestataires via leur liste dédiée et produit `providers.json` (§ 9). + +Volumétrie migrable (tous groupes confondus) : + +| Objet source | Quantité | Cible Starseed | +|---|---|---| +| Tiers racine | 1435 | `Client` (1129) + `Supplier` (412) = **1541 entités** créées (les 106 mixtes comptent double) | +| Adresses | **2282** | `ClientAddress` / `SupplierAddress` | +| Contacts | **2704** | `ClientContact` / `SupplierContact` | +| RIB | **0** | *rien à migrer (ni IBAN ni BIC dans l'export)* | + +Détail par groupe : + +| Groupe | Tiers | Adresses | Contacts | Avec tél | Avec catégorie | Avec type paiement | +|---|---|---|---|---|---|---| +| Clients | 1023 | 1125 | 1279 | 1023 (100 %) | 648 (63 %) | 599 | +| Fournisseurs | 306 | 585 | 758 | 306 (100 %) | 92 (30 %) | 283 | +| Mixtes | 106 | 572 | 667 | 106 (100 %) | 37 (35 %) | 95 | + +Points de décision restants (détaillés plus bas) : + +1. **Les 106 tiers mixtes** doivent être dédoublés en un `Client` + un `Supplier` (le modèle Starveed sépare les deux entités). § 4. +2. **Référentiels à compléter** : `paymentDelay` (« 20 jours » absent du seed), `tvaMode` (Intracom/Export/achats), mapping des ~100 catégories. § 5. +3. **Site obligatoire par adresse** (min 1) — notion toujours absente de l'export. § 6. + +--- + +## 2. Structure de l'export + +Chaque ligne = un tiers. Les champs `addresses.*` et `contacts.*` sont des **listes parallèles** (un tiers peut avoir plusieurs adresses / contacts). + +Qualité des données (tous groupes) : + +- **Téléphone racine** : rempli sur **100 %** des tiers (1435/1435). +- **Emails contacts** : 1257 présents, **1256 valides** (1 invalide). +- **Codes postaux** : 2282 présents, **10 invalides** à nettoyer : `.` (×3), `B.5300`, `?`, `684`, `854300`, etc. (sinon 422 sur la regex 4-5 chiffres RG-1.09/2.05). +- **Catégories** : 55 % des tiers en ont au moins une (45 % sans) ; **92 % des adresses** ont une catégorie (2104/2282). +- **Noms de contact** : format « `CIVILITÉ NOM PRÉNOM` » (`M `, `Mme`, `M.`) → à parser pour `lastName` / `firstName`. + +Pays des adresses : FR (2266), ES (11), BE (2), NL (2), PT (1). + +--- + +## 3. Mapping champ par champ + +Légende : ✅ champ existant · ⚠️ migrable avec transformation · ❌ pas de cible (donnée perdue) · 🔒 contrainte de validation à gérer. + +### 3.1 Niveau Tiers → `Client` / `Supplier` + +| Champ Mixgraine | Cible Starseed | Statut | Note de transformation | +|---|---|---|---| +| `name` / `reference` | `companyName` (≤180) | ✅ | Vérifier l'unicité (index partiel `LOWER(company_name)`). Risque : un même nom présent en client ET fournisseur reste OK (tables distinctes) ; doublons intra-table à contrôler. | +| `phone` (tél de l'objet de base) | `*Contact.phonePrimary` | ⚠️ | **Migré dans la liste de contacts** (cf. § 3.3bis), jamais au niveau racine. Rempli à 100 %. | +| `categoriesStr` / `category.name` | `categories` (M2M `Category`) | ⚠️🔒 | **Min 1 obligatoire**. ~100 valeurs distinctes à mapper vers le référentiel `Category` (§ 5.1). Attention `Courtier`/`Distributeur` (cf. broker/distributor). | +| `liability.name` | `tvaMode` | ⚠️ | `France (ventes)`→`FRANCE_VENTES` (1426), `Intracom (ventes)`→`INTRACOM_VENTES` (6), `Export (ventes)`→`EXPORT_VENTES` (2), `France (achats)`→**pas de mode « achats » au seed** (1 cas). | +| `paymentDelay.label` | `paymentDelay` | ⚠️🔒 | `15 jours`→`J15` (1376), `30 jours`→`J30` (43), `A réception`→`A_RECEPTION` (1), **`20 jours` (15)→ code `J20` à créer** (absent du seed). | +| `paymentType.label` | `paymentType` | ⚠️🔒 | `LCR non soumise`→**à trancher** (`LCR` impose ≥1 RIB, or 0 RIB → préférer `NON_SOUMISE`), `Virement`→`VIREMENT` (95, **exige `bank`** absente de la source), `Chèque`→`CHEQUE` (28). | +| `billingAccount` | `accountNumber` (≤40) | ✅ | Peu rempli. | +| `distributor.name` | `distributor` (FK auto-réf. `Client`) | ⚠️ | Suppose que le distributeur existe comme `Client`. Pas d'équivalent côté `Supplier`. | +| `courtier.id` / `courtier.name` | `broker` (FK auto-réf.) | ⚠️ | À vérifier sur les nouveaux exports (vide dans l'ancien). Mutuellement exclusif avec `distributor` (RG-1.03). | +| `customer` / `supplier` | choix de l'entité cible (`Client` vs `Supplier`) | ⚠️ | **Sert au routage**, pas stocké tel quel. | +| `tiersIndexable`, `prestataire`, `coreLocked`, `isOgm`, `maxOwed`, `autoGenerateInvoice` | — | ❌ | Flags Mixgraine sans équivalent métier. | +| `organization1..3` / `organizationsStr` | — | ❌ | Notion d'organisation/agence absente du modèle. | +| `articlesSold.*` | — | ❌ | Relève du catalogue, hors périmètre Client/Supplier. | +| `siren`, `nTva`, `bank` | `siren`, `nTva`, `bank` | ❌ | **Non présents dans l'export** → resteront vides. | + +### 3.2 Niveau Adresse → `ClientAddress` / `SupplierAddress` + +| Champ Mixgraine | Cible Starseed | Statut | Note | +|---|---|---|---| +| `addresses.street1` | `street` (≤255) | ✅ | Obligatoire. | +| `addresses.street2` | `streetComplement` (≤255) | ✅ | | +| `addresses.postcode` | `postalCode` (≤20, regex 4-5 chiffres) | ⚠️ | 10 valeurs à nettoyer. | +| `addresses.city` | `city` (≤120) | ✅ | Obligatoire. | +| `addresses.country` | `country` (≤80, défaut « France ») | ⚠️ | Convertir code ISO → libellé (`FR`→France, `ES`→Espagne, `BE`→Belgique, `NL`→Pays-Bas, `PT`→Portugal). | +| `addresses.lat` / `addresses.lng` | — | ❌ | Coordonnées GPS perdues (pas de champ géo). | +| `addresses.billing` (true) | `isBilling` (Client) | ⚠️🔒 | Si `true`, `billingEmail` **obligatoire** (RG-1.11), absent de la source → soit ne pas marquer facturation, soit collecter l'email. | +| `addresses.sales` | `isDelivery` (Client) | ⚠️ | | +| `addresses.purchase` | type adresse (Supplier : `DEPART`/`RENDU`) | ⚠️ | Côté fournisseur, l'adresse a un enum `PROSPECT`/`DEPART`/`RENDU`. | +| `addresses.salesTrip` | `isProspect` (?) | ⚠️ | Sémantique « tournée commerciale » à confirmer. | +| `addresses.benneCount` | `bennes` (SupplierAddress) | ⚠️ | Souvent vide. | +| `addresses.carrierTypeStr`, `addresses.name`, `addresses.distances` | — | ❌ | Sans cible. | +| `addresses.categories.name` | `categories` (M2M adresse) | ⚠️🔒 | 92 % des adresses en ont une. **Min 1 obligatoire** côté `ClientAddress`. ⚠️ Codes `DISTRIBUTEUR`/`COURTIER` **interdits** au niveau adresse (RG-1.29). | +| — (aucun) | `sites` (M2M, **min 1**) | 🔒 | **Notion absente de l'export** — bloquant majeur (§ 6). | + +### 3.3 Niveau Contact → `ClientContact` / `SupplierContact` + +| Champ Mixgraine | Cible Starseed | Statut | Note | +|---|---|---|---| +| `contacts.name` | `lastName` / `firstName` (≤120) | ⚠️ | Parser « CIVILITÉ NOM PRÉNOM » : retirer civilité, 1er token = `lastName`, reste = `firstName`. Au moins l'un des deux requis (RG-1.05/2.04). | +| `contacts.function` | `jobTitle` (≤120) | ✅ | | +| `contacts.email` | `email` (≤180) | ✅ | 99,9 % valides. | +| `contacts.phone` | `phonePrimary` (≤20) | ✅ | | +| `contacts.mobile` | `phoneSecondary` (≤20) | ✅ | | +| `contacts.fax` | — | ❌ | Pas de champ fax dans les contacts Starseed. | +| `contacts.siege` | — | ❌ | Notion « contact siège » non modélisée. | +| `addresses.contacts.*` | `*Address.contacts` (M2M) | ⚠️ | Rattachement contact↔adresse via les ids `addresses.contacts.id`. | + +### 3.3bis — Règle : le contact porté par l'objet de base va dans la liste de contacts + +Starseed n'a **aucun champ contact au niveau racine** de `Client`/`Supplier`. Tout doit atterrir dans la collection `*Contact` : + +- Le bloc `contacts.*` (nom, fonction, email, tél, mobile) → une entrée `*Contact` par contact. +- Le `phone` de l'objet de base (rempli à 100 %) → reporté sur un contact : + - si le numéro figure déjà sur un contact → ne rien créer (déduplication) ; + - s'il diffère → l'ajouter comme `phoneSecondary` d'un contact existant ; + - si le tiers **n'a aucun contact** → **créer un contact « Standard »** (`lastName = "Standard"` pour respecter RG-1.05/2.04) portant ce `phonePrimary`. + +Conséquence : aucun tiers ne perd son téléphone, et rien n'est stocké au niveau racine (conforme au modèle Starseed). + +### 3.4 Niveau RIB → `ClientRib` / `SupplierRib` + +**Disponible via l'endpoint unitaire** (champ `banks[]`), absent de l'export en masse. Mapping direct : + +| Champ Mixgraine (`banks[]`) | Cible Starseed (`*Rib`) | Statut | +|---|---|---| +| `banks[].label` | `label` (≤120) | ✅ | +| `banks[].iban` | `iban` (≤34) | ✅ (validé Assert\Iban) | +| `banks[].bic` | `bic` (≤20) | ✅ (validé Assert\Bic) | + +⚠️ Toujours mapper `paymentType="LCR non soumise"` vers `NON_SOUMISE` (et pas `LCR`) : les tiers sans `banks[]` violeraient sinon RG-1.13/2.08 (min 1 RIB). + +### 3.5 Champs supplémentaires de l'endpoint unitaire (absents de l'export en masse) + +| Champ Mixgraine (détail / `__data`) | Cible Starseed | Statut | Note | +|---|---|---|---| +| `vatNumber` | `nTva` (≤40) | ✅ | N° TVA. | +| `accountingBank` (1=CIC, 2=SOCIETE GENERALE, 3=CREDIT AGRICOLE) | `bank` (FK) | ✅ | **Correspond au seed Starseed** (CIC/SG/CA). Banque de virement. | +| `courtier` (FK select, 18 valeurs) | `broker` (FK auto-réf.) | ✅ | Courtier réel (≠ catégorie « Courtier »). Exclusif avec `distributor` (RG-1.03). | +| `distributor` (FK select, 14 valeurs) | `distributor` (FK auto-réf.) | ✅ | Distributeur réel. | +| `mails[]` {`mail`, `invoice`} | `ClientAddress.billingEmail` / secondaire | ⚠️ | Emails d'envoi de documents ; `invoice=true` → email de facturation (RG-1.11). | +| address `carrierType` (1=Rendu, 2=Départ) | `SupplierAddress.addressType` (RENDU/DEPART) | ⚠️ | Radio exclusif côté fournisseur (RG-2.09). | +| address `organization_1/2/3` | `*Address.sites` (M2M) | ✅ | **Les sites** (cf. § 6). | +| address `hasBenne` / `benneCount` | `SupplierAddress.bennes` | ⚠️ | Nombre de bennes. | +| address `validated` | — | ❌ | « Adresse vérifiée », pas de cible. | +| `isDistributor`, `directBilling`, `distributorDirectOrder`, `newGuarantees`, `commentMail`, `zoneChartering(2)`, `emailDocument*` | — | ❌ | Champs Mixgraine sans équivalent métier. | + +--- + +## 4. Tiers mixtes (client + fournisseur) — 106 cas + +Les 106 tiers du fichier `fournisseur-client.json` sont **à la fois client et fournisseur**. Le modèle Starseed sépare strictement `Client` et `Supplier` (deux tables, deux jeux de sous-entités). Deux options : + +- **(Recommandé)** Créer **deux entités** par tiers mixte : un `Client` et un `Supplier`, chacun avec sa copie des adresses/contacts. C'est cohérent avec le modèle, au prix d'une duplication (à reconcilier plus tard si un lien inter-entités est ajouté). +- Choisir une seule face (client OU fournisseur) selon l'usage dominant — risque de perte d'information. + +Impact volumétrie : 1023 + 106 = **1129 `Client`**, 306 + 106 = **412 `Supplier`**. + +--- + +## 5. Référentiels à préparer avant import + +### 5.1 Catégories (`Category`) + +Situation nettement meilleure que l'export initial : **55 % des tiers** et **92 % des adresses** portent une catégorie. L'export contient **~100 valeurs distinctes** (top : Eleveur 392, Semencier 61, Coopérative 53, Courtier 38, Négociant 30, …, puis une longue traîne de catégories « fournisseur » métier : Transports, Informatique, Garagistes, Banques…). + +À faire : +- **Construire le référentiel `Category`** depuis ces valeurs (dédoublonner casse/accents, ex. `Courtier`/`COURTIERS`). +- **Politique pour les 45 % sans catégorie** : attribuer une catégorie par défaut (ex. `À QUALIFIER`) pour satisfaire la contrainte min 1. +- **Niveau adresse** : exclure `DISTRIBUTEUR`/`COURTIER` (interdits RG-1.29) — les router vers les FK `distributor`/`broker` ou la catégorie tiers. +- Distinguer catégories **type CLIENT** vs **type FOURNISSEUR** (RG-2.10 : une catégorie fournisseur est exigée sur un `Supplier`). + +### 5.2 Référentiels comptables + +| Référentiel | Valeurs export | Action | +|---|---|---| +| `paymentDelay` | 15 jours, 30 jours, **20 jours**, A réception | Le seed n'a que `J15`/`J30`/`A_RECEPTION` → **créer `J20`**. | +| `paymentType` | LCR non soumise, Virement, Chèque | Mapper `LCR non soumise`→`NON_SOUMISE` (éviter la contrainte RIB) ; `Virement`→`VIREMENT` (mais `bank` absente → soit la laisser vide en assouplissant RG-1.12, soit collecter). | +| `tvaMode` | France (ventes), Intracom (ventes), Export (ventes), **France (achats)** | Mapper sur `FRANCE_VENTES`/`INTRACOM_VENTES`/`EXPORT_VENTES` ; pas de mode « achats » au seed (1 cas) → décision. | + +--- + +## 6. Sites — résolu via les « organisations » Mixgraine + +Chaque `ClientAddress`/`SupplierAddress` doit référencer **au moins un `Site`** (RG-1.10/2.06). Cette notion **existe dans Mixgraine** sous le nom d'**organisations**, visible dans l'endpoint unitaire : + +- au niveau tiers : `organizationsStr` (ex. `"Châtellerault, Saint-Jean, Pommevic"`) ; +- au niveau adresse : trois booléens `organization_1` / `organization_2` / `organization_3` étiquetés **Châtellerault / Saint-Jean / Pommevic** ; +- le bloc `addresses.distances` donne même la distance de l'adresse vers chacun de ces 3 sites. + +**Les 3 sites à créer dans Starseed** : `Châtellerault`, `Saint-Jean`, `Pommevic`. Le rattachement adresse↔site se reconstruit depuis les booléens `organization_n=true`. Pour une adresse sans aucune organisation cochée, prévoir un site par défaut (à décider). + +⚠️ Ce rattachement n'est lisible que via l'endpoint unitaire — raison de plus pour migrer depuis le détail (cf. § 9). + +--- + +## 7. Synthèse des pertes de données + +Perdus si non traités en amont : coordonnées GPS (`lat`/`lng`), fax des contacts, contact siège, articles vendus, « adresse vérifiée », et divers flags Mixgraine (`isOgm`, `maxOwed`, `directBilling`, `zoneChartering`, `commentMail`). + +Champs cibles qui resteront vides (vraiment absents de la source, même au détail) : `siren` (le `vatNumber` alimente `nTva`, mais pas de SIREN distinct). + +> Note : `nTva`, `bank` et les `*Rib` ne sont **plus** des pertes — ils sont disponibles via l'endpoint unitaire (§ 3.4 / § 3.5). Ils n'étaient absents que de l'export en masse. + +--- + +## 7bis. Excel de relecture (un onglet par type) + +Beaucoup d'adresses n'ont **aucun site** rattaché (les organisations ne sont pas cochées dans Mixgraine), et d'autres trous existent (CP invalide, facturation sans email…). Avant tout import, on produit un classeur via `build_tiers_xlsx.py` : **`mixgraine-tiers.xlsx`**. + +- Un onglet **Synthèse** (compteurs) + un onglet par type : **Clients**, **Fournisseurs**, **Prestataires**. +- Chaque onglet liste toutes les données, **une ligne par adresse** (colonnes du tiers répétées). +- Colonne **Site manquant** (OUI/vide) + **filtre automatique** → un clic pour isoler les adresses sans site. Colonne **Problèmes** pour le reste. Lignes à problème surlignées. + +Chiffres réels (extraction 2026-06-16, 1442 tiers) : + +| Type | Tiers | Lignes (adresses) | Adresses sans site | Sans catégorie | +|---|---|---|---|---| +| Clients | 1129 | 1697 | 1111 | 444 (39 %) | +| Fournisseurs | 412 | 1157 | 612 | 283 (68 %) | +| Prestataires | 300 | 333 | 0 | 8 (2 %) | +| **Total** | **1841\*** | **3187** | **1723** | **735** | + +\* mixtes comptés en Client + Fournisseur. RIB extraits : 90. Tiers totalement bloqués : 90. + +Motifs de blocage détectés : + +| Niveau | Motif bloquant | +|---|---| +| Adresse | **aucun site rattaché** (RG-1.10/2.06/3.03), code postal absent/invalide, ville absente, rue absente, facturation sans email (client) | +| Tiers | nom absent, `VIREMENT` sans banque, `LCR` sans RIB, **prestataire sans aucun site** (RG-3.03), catégorie `À QUALIFIER` | + +Filtrer « Site manquant = OUI » dans chaque onglet permet de corriger la donnée à la source (re-cocher les organisations dans Mixgraine, compléter les emails) **avant** de relancer l'import (le cache accélère). + +--- + +## 8. Séquence d'import recommandée + +1. **Extraire la donnée complète** depuis l'endpoint unitaire (§ 9) — pas depuis l'export en masse. +2. **Relecture** : `build_tiers_xlsx.py` → `mixgraine-tiers.xlsx` (un onglet par type, filtre « Site manquant »), faire corriger à la source (§ 7bis). +2. **Sites** : créer les 3 sites (Châtellerault, Saint-Jean, Pommevic) + un site par défaut éventuel (§ 6). +3. **Référentiels** : créer/compléter `Category` (~100 valeurs + défaut `À QUALIFIER`), `paymentDelay` (ajouter `J20`), `tvaMode`, vérifier `paymentType`, `bank` (CIC/SG/CA déjà OK) (§ 5). +4. **Nettoyer** : 10 codes postaux invalides, parser les noms de contacts (civilité), convertir codes pays ISO → libellés (table fournie par le schéma `country`). +5. **Importer dans l'ordre** : référentiels + sites → tiers → adresses (+ rattachement site via `organization_n`) → contacts (+ rattachement adresse + tél de l'objet de base) → RIB (`banks[]`). +6. **Tiers mixtes** (106) : créer Client + Supplier (§ 4). +7. **Gérer les 422 attendus** : adresses `isBilling=true` sans `billingEmail` ; `paymentType=VIREMENT` sans `bank`. + +--- + +## 9. Stratégie d'extraction via l'API Mixgraine + +L'export en masse est insuffisant (§ entête). La donnée complète s'obtient en deux temps depuis `https://liot.mixsuite.fr` (auth : `Authorization: Bearer `) : + +1. **Lister les ids par groupe, page par page** : + `GET /api/customer/?fields=["name"]&filters={...}&limit=200&order=name&page=N` + La réponse porte `count` (total) et `data[].id`. On pagine **trois listes filtrées** puis on fait l'union dédupliquée : + - `{"customer":true}` → clients (`Client`), + - `{"supplier":true}` → fournisseurs (`Supplier`), + - `{"prestataire":true}` → prestataires (`Provider`, module Technique). + + La **classification se fait par appartenance** à ces listes (plus fiable que les flags du formulaire `__data`) : un id dans `customer` ET `supplier` = mixte (Client + Supplier) ; un id dans `prestataire` = `Provider`. Le script produit `clients.json`, `suppliers.json`, `providers.json` + `referentials.json`. + +2. **Récupérer le détail complet de chaque tiers** : + `GET /api/customer/{id}` (objet riche : contacts, addresses, banks, liability, paymentType…) + ou `PUT /api/customer/{id}` avec corps `{"__data":true}` qui renvoie en plus le **schéma de formulaire** (libellés des organisations/sites, choix des référentiels, FK distributor/courtier) **et** les valeurs sous `__data`. + +Champs clés présents uniquement au détail : `banks[]` (RIB), `vatNumber`, `accountingBank`, `courtier`, `distributor`, `mails[]`, `addresses[].organization_1/2/3` (sites), `addresses[].carrierType`, `details.geo` (lat/lng par adresse). + +> ⚠️ Le JWT fourni est un secret de session : ne pas le committer dans le repo. À passer en variable d'environnement au script d'extraction. + +--- + +## 📦 Tickets Lesstime générés + +TaskGroup Lesstime : **#32 — Migration tiers Mixgraine → Starseed** (projet STARSEED / ERP) + +| # | Ticket | Réf. | Effort | Tag | +|---|---|---|---|---| +| 1.1 | Extraire et normaliser les tiers Mixgraine en JSON | ERP-174 | M | Backend | +| 1.2 | Seeder les référentiels et les 3 sites | ERP-175 | M | Backend | +| 1.3 | Importer les clients (Commercial) + adresses/contacts/RIB | ERP-176 | L | Backend | +| 1.4 | Importer les fournisseurs (Commercial) + adresses/contacts/RIB | ERP-177 | M | Backend | +| 1.5 | Importer les prestataires (Technique/Provider) | ERP-178 | M | Backend | +| 1.6 | Relier distributeurs/courtiers, traiter les mixtes et vérifier | ERP-179 | M | Backend | + +Tous au statut **Prêt à dev**. Prochaine action : lancer le 1.1 (le script `extract_mixgraine.py` est déjà écrit, reste à le tester avec un vrai token). + +--- + +*Document basé sur l'analyse des exports en masse `client.json.json` / `fournisseur.json` / `fournisseur-client.json` et sur les réponses des endpoints `/api/customer/` (liste), `/api/customer/{id}` (détail) et `PUT /api/customer/{id}` (`__data`). Aucune donnée n'a été importée — rapport d'analyse préalable.* diff --git a/docs/migration/run.sh b/docs/migration/run.sh new file mode 100755 index 0000000..3e6a235 --- /dev/null +++ b/docs/migration/run.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Enchaine extraction Mixgraine + generation des Excel de relecture. +# Usage : export MIXGRAINE_JWT="eyJ..." && ./run.sh +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ -z "${MIXGRAINE_JWT:-}" ]]; then + echo "ERREUR : export MIXGRAINE_JWT='' avant de lancer." >&2 + echo " (Chrome -> F12 -> Network -> requete api/customer -> header authorization: Bearer ...)" >&2 + exit 1 +fi + +OUT="${1:-mixgraine-export}" + +echo "==> 1/2 Extraction Mixgraine (lent, ~1 req/s)..." +python3 extract_mixgraine.py --out "$OUT" + +echo "==> 2/2 Generation de l'Excel par type..." +python3 build_tiers_xlsx.py --in "$OUT" + +echo +echo "Termine. Resultats dans : $OUT/" +echo " - mixgraine-tiers.xlsx (1 onglet par type + Synthese, filtre 'Site manquant')" +echo " - *.json + extraction-report.txt"