Files
colibre/docs/superpowers/plans/2026-07-03-cartes-acheteur-titulaire-contrepartie.md

33 KiB

Cartes /acheteur et /titulaire : afficher la contrepartie Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Sur /acheteurs/<id> et /titulaires/<id>, remplacer la carte Plotly à point unique (basée sur l'annuaire) par une carte dash-leaflet clusterisée qui affiche l'organisme consulté et sa contrepartie (titulaires pour un acheteur, acheteurs pour un titulaire).

Architecture: Extraction d'une fonction partagée build_org_markers (déjà dupliquée en boucle dans get_geographic_maps d'/observatoire) puis nouvelle fonction get_org_location_map qui assemble deux couches dl.GeoJSON clusterisées (une par type d'organisme) avec cadrage automatique (bounds). Un nouveau callback par page, déclenché par l'URL et le filtre Année, appelle cette fonction ; l'ancien callback annuaire perd la responsabilité de la carte.

Tech Stack: Python, Polars, DuckDB, Dash, dash-leaflet (dl), dash-leaflet.express (dlx), dash-extensions (Namespace).

Global Constraints

  • Couleurs des marqueurs : acheteur #E69F00 (orange), titulaire #56B4E9 (bleu ciel) — identiques à /observatoire.
  • La carte suit le filtre Année de la page (acheteur_year/titulaire_year), comme les tableaux Top titulaires/acheteurs.
  • Cadrage automatique (fitBounds) sur l'organisme + toutes ses contreparties, avec padding léger (boundsOptions={"padding": [30, 30], "maxZoom": 12}).
  • Repli sur une vue France fixe (center=[46.6, 2.2], zoom=5) si aucune coordonnée exploitable n'est disponible (dataset sans colonnes longitude/latitude, ou organisme sans marché géolocalisé) — jamais d'exception.
  • Pas de découpage par région/DOM-TOM ni de bascule chloroplèthe sur /acheteur//titulaire (réservé à /observatoire).
  • Les contrôles de zoom +/- natifs de dl.Map (Leaflet) doivent rester actifs — ne jamais passer zoomControl=False.
  • Imports internes toujours via src. (ex. from src.figures import ...), jamais figures.py en import relatif nu.
  • Lancer les tests avec uv run pytest (l'activation du venv via l'outil Bash n'est pas fiable dans cet environnement).

File Structure

  • Modify src/figures.py : ajoute ORG_COLORS, build_org_markers, get_org_location_map ; met à jour get_geographic_maps et make_clusters_map pour réutiliser build_org_markers/ORG_COLORS ; supprime point_on_map (devient mort après migration des deux pages).
  • Modify src/pages/acheteur.py : nouveau callback update_acheteur_map ; update_acheteur_infos perd la construction de la carte.
  • Modify src/pages/titulaire.py : nouveau callback update_titulaire_map ; update_titulaire_infos perd la construction de la carte.
  • Test tests/test_figures.py : tests unitaires de build_org_markers et get_org_location_map.

Task 1: Extraire build_org_markers et l'utiliser dans get_geographic_maps

Files:

  • Modify: src/figures.py:410-551 (fonction get_geographic_maps), src/figures.py:582-633 (fonction make_clusters_map)
  • Test: tests/test_figures.py (ajout en fin de fichier)

Interfaces:

  • Produces: ORG_COLORS: dict[str, str] (clés "acheteur"/"titulaire") ; build_org_markers(lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]) -> list[dict] où chaque dict a les clés "lat", "lon", "tooltip", "marker_color". Renvoie [] (sans exception) si les colonnes {org_type}_longitude/{org_type}_latitude sont absentes du LazyFrame.

  • Step 1: Écrire les tests (qui vont échouer, build_org_markers n'existe pas encore)

Ajouter à la fin de tests/test_figures.py :

def test_build_org_markers_groups_and_counts():
    from src.figures import build_org_markers

    lff = pl.LazyFrame(
        [
            {
                "uid": "u1",
                "acheteur_longitude": 2.35,
                "acheteur_latitude": 48.85,
                "acheteur_nom": "ACHETEUR A",
            },
            {
                "uid": "u2",
                "acheteur_longitude": 2.35,
                "acheteur_latitude": 48.85,
                "acheteur_nom": "ACHETEUR A",
            },
            {
                "uid": "u3",
                "acheteur_longitude": -1.68,
                "acheteur_latitude": 48.11,
                "acheteur_nom": "ACHETEUR B",
            },
        ]
    )

    markers = build_org_markers(lff, "acheteur")

    assert len(markers) == 2
    marker_a = next(m for m in markers if m["tooltip"].startswith("ACHETEUR A"))
    assert marker_a["tooltip"] == "ACHETEUR A (2 marchés)"
    assert marker_a["lat"] == 48.85
    assert marker_a["lon"] == 2.35
    assert marker_a["marker_color"] == "#E69F00"


def test_build_org_markers_filters_null_coordinates():
    from src.figures import build_org_markers

    lff = pl.LazyFrame(
        [
            {
                "uid": "u1",
                "acheteur_longitude": None,
                "acheteur_latitude": None,
                "acheteur_nom": "ACHETEUR A",
            },
            {
                "uid": "u2",
                "acheteur_longitude": 2.35,
                "acheteur_latitude": 48.85,
                "acheteur_nom": "ACHETEUR B",
            },
        ]
    )

    markers = build_org_markers(lff, "acheteur")

    assert len(markers) == 1
    assert markers[0]["tooltip"] == "ACHETEUR B (1 marchés)"


def test_build_org_markers_missing_columns_returns_empty():
    from src.figures import build_org_markers

    lff = pl.LazyFrame([{"uid": "u1", "acheteur_nom": "ACHETEUR A"}])

    assert build_org_markers(lff, "acheteur") == []
    assert build_org_markers(lff, "titulaire") == []
  • Step 2: Lancer les tests pour vérifier qu'ils échouent

Run: uv run pytest tests/test_figures.py -k build_org_markers -v Expected: FAIL avec ImportError: cannot import name 'build_org_markers'

  • Step 3: Ajouter ORG_COLORS et build_org_markers, refactoriser get_geographic_maps et make_clusters_map

Dans src/figures.py, insérer juste avant def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list: (ligne 410) :

ORG_COLORS = {
    "acheteur": "#E69F00",  # orange
    "titulaire": "#56B4E9",  # bleu ciel
}


def build_org_markers(
    lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]
) -> list[dict]:
    """Regroupe les marchés par point géographique pour un type d'organisme.

    Renvoie [] (sans exception) si les colonnes longitude/latitude de ce
    type sont absentes du LazyFrame (ex: tests/test.parquet).
    """
    lon_col = f"{org_type}_longitude"
    lat_col = f"{org_type}_latitude"
    nom_col = f"{org_type}_nom"

    available = set(lff.collect_schema().names())
    if lon_col not in available or lat_col not in available:
        return []

    lff_org = (
        lff.select("uid", lon_col, lat_col, nom_col)
        .group_by(lon_col, lat_col, nom_col)
        .len("nb_marches")
        .filter(pl.col(lat_col).is_not_null() & pl.col(lon_col).is_not_null())
    )

    return [
        {
            "lat": row[lat_col],
            "lon": row[lon_col],
            "tooltip": f"{row[nom_col]} ({row['nb_marches']} marchés)",
            "marker_color": ORG_COLORS[org_type],
        }
        for row in lff_org.collect().to_dicts()
    ]

Dans le corps de get_geographic_maps (fonction interne make_map_data), remplacer le bloc actuel :

        else:
            _map_type: str = "clusters"
            for org_type in ["acheteur", "titulaire"]:
                lff_org = (
                    lff.select(
                        "uid",
                        f"{org_type}_longitude",
                        f"{org_type}_latitude",
                        f"{org_type}_nom",
                    )
                    .group_by(
                        f"{org_type}_longitude",
                        f"{org_type}_latitude",
                        f"{org_type}_nom",
                    )
                    .len("nb_marches")
                    .filter(
                        pl.col(f"{org_type}_latitude").is_not_null()
                        & pl.col(f"{org_type}_longitude").is_not_null()
                    )
                )

                markers = []

                # Couleurs accessibles (Okabe-Ito)
                colors = {
                    "acheteur": "#E69F00",  # orange
                    "titulaire": "#56B4E9",  # bleu ciel
                }

                for row in lff_org.collect().to_dicts():
                    markers.append(
                        {
                            "lat": row[f"{org_type}_latitude"],
                            "lon": row[f"{org_type}_longitude"],
                            "tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
                            "marker_color": colors[org_type],
                        }
                    )
                dfs.append(markers)

par :

        else:
            _map_type: str = "clusters"
            for org_type in ["acheteur", "titulaire"]:
                dfs.append(build_org_markers(lff, org_type))

Dans make_clusters_map, remplacer :

    # Couleurs
    color_acheteur = region_acheteurs[0]["marker_color"]
    color_titulaire = region_titulaires[0]["marker_color"]

par (évite un IndexError maintenant que build_org_markers peut renvoyer une liste vide) :

    # Couleurs
    color_acheteur = ORG_COLORS["acheteur"]
    color_titulaire = ORG_COLORS["titulaire"]
  • Step 4: Lancer les tests pour vérifier qu'ils passent

Run: uv run pytest tests/test_figures.py -v Expected: PASS (tous les tests, y compris les 3 nouveaux)

  • Step 5: Lancer la suite complète pour vérifier l'absence de régression

Run: uv run pytest Expected: tous les tests passent (identique à avant la modification)

  • Step 6: Commit
git add src/figures.py tests/test_figures.py
git commit -m "$(cat <<'EOF'
refactor(figures): extraire build_org_markers pour réutilisation

EOF
)"

Task 2: Ajouter get_org_location_map

Files:

  • Modify: src/figures.py (nouvelle fonction, à la suite de make_clusters_map, ligne 633)
  • Test: tests/test_figures.py

Interfaces:

  • Consumes: ORG_COLORS, build_org_markers (Task 1)

  • Produces: get_org_location_map(dff: pl.DataFrame, home_type: Literal["acheteur", "titulaire"], map_id: str) -> dl.Map

  • Step 1: Écrire les tests (qui vont échouer, la fonction n'existe pas encore)

Ajouter à la fin de tests/test_figures.py :

def test_get_org_location_map_bounds_cover_home_and_counterpart():
    import dash_leaflet as dl

    from src.figures import get_org_location_map

    dff = pl.DataFrame(
        [
            {
                "uid": "u1",
                "acheteur_longitude": 2.35,
                "acheteur_latitude": 48.85,
                "acheteur_nom": "ACHETEUR A",
                "titulaire_longitude": -1.68,
                "titulaire_latitude": 48.11,
                "titulaire_nom": "TITULAIRE A",
            }
        ]
    )

    leaflet_map = get_org_location_map(dff, "acheteur", "test-map")

    assert isinstance(leaflet_map, dl.Map)
    assert leaflet_map.bounds == [[48.11, -1.68], [48.85, 2.35]]

    geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)]
    assert {layer.id for layer in geojson_layers} == {
        "test-map-acheteur",
        "test-map-titulaire",
    }


def test_get_org_location_map_defaults_to_france_view_without_coordinates():
    from src.figures import get_org_location_map

    dff = pl.DataFrame(
        [{"uid": "u1", "acheteur_nom": "ACHETEUR A", "titulaire_nom": "TITULAIRE A"}]
    )

    leaflet_map = get_org_location_map(dff, "acheteur", "test-map")

    assert leaflet_map.center == [46.6, 2.2]
    assert leaflet_map.zoom == 5
  • Step 2: Lancer les tests pour vérifier qu'ils échouent

Run: uv run pytest tests/test_figures.py -k get_org_location_map -v Expected: FAIL avec ImportError: cannot import name 'get_org_location_map'

  • Step 3: Implémenter get_org_location_map

Dans src/figures.py, ajouter à la suite de make_clusters_map (après la ligne return leaflet_map qui la termine) :

def get_org_location_map(
    dff: pl.DataFrame,
    home_type: Literal["acheteur", "titulaire"],
    map_id: str,
) -> dl.Map:
    """Carte cluster (dash-leaflet) d'un organisme et de sa contrepartie.

    Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire
    consultée) ainsi que ceux de son type complémentaire, clusterisés et
    colorés comme sur /observatoire. Cadrage automatique (fitBounds) sur
    l'ensemble des points ; repli sur une vue France fixe si aucun point
    n'est disponible.
    """
    counterpart_type: Literal["acheteur", "titulaire"] = (
        "titulaire" if home_type == "acheteur" else "acheteur"
    )

    lff = dff.lazy()
    markers_by_type = {
        "acheteur": build_org_markers(lff, "acheteur"),
        "titulaire": build_org_markers(lff, "titulaire"),
    }

    ns = Namespace("dash_clientside", "leaflet")
    point_to_layer = ns("pointToLayer")
    cluster_to_layer = ns("clusterToLayer")

    layers: list = [dl.TileLayer()]
    all_points: list[tuple[float, float]] = []
    # Ordre fixe (titulaire puis acheteur) pour que l'organisme consulté
    # soit toujours peint au-dessus de sa contrepartie, comme sur /observatoire.
    for org_type in ("titulaire", "acheteur"):
        markers = markers_by_type[org_type]
        if not markers:
            continue
        all_points.extend((m["lat"], m["lon"]) for m in markers)
        layers.append(
            dl.GeoJSON(
                data=dlx.dicts_to_geojson(markers),
                cluster=True,
                zoomToBoundsOnClick=True,
                pointToLayer=point_to_layer,
                clusterToLayer=cluster_to_layer,
                id=f"{map_id}-{org_type}",
                options={"fillColor": ORG_COLORS[org_type]},
            )
        )

    map_kwargs: dict = {}
    if all_points:
        lats = [lat for lat, _ in all_points]
        lons = [lon for _, lon in all_points]
        map_kwargs["bounds"] = [[min(lats), min(lons)], [max(lats), max(lons)]]
        map_kwargs["boundsOptions"] = {"padding": [30, 30], "maxZoom": 12}
    else:
        map_kwargs["center"] = [46.6, 2.2]
        map_kwargs["zoom"] = 5

    return dl.Map(
        layers,
        style={"width": "100%", "height": "300px"},
        id=map_id,
        **map_kwargs,
    )

Note : home_type et counterpart_type ne sont pas utilisés pour changer le contenu affiché (les deux types sont toujours construits et affichés) — home_type sert uniquement à documenter l'intention de l'appelant et pourrait être utilisé plus tard pour un style différencié. Garder le paramètre tel quel (il est requis par l'appelant pour construire les colonnes de la requête, cf. Task 3/4).

  • Step 4: Lancer les tests pour vérifier qu'ils passent

Run: uv run pytest tests/test_figures.py -v Expected: PASS (tous les tests, y compris les 2 nouveaux)

  • Step 5: Commit
git add src/figures.py tests/test_figures.py
git commit -m "$(cat <<'EOF'
feat(figures): ajouter get_org_location_map (carte cluster organisme + contrepartie)

EOF
)"

Task 3: Brancher la carte sur /acheteurs/<id>

Files:

  • Modify: src/pages/acheteur.py

Interfaces:

  • Consumes: get_org_location_map (Task 2), _acheteur_scope(pathname, ach_year) -> tuple[str, list] (déjà existant, src/pages/acheteur.py:50), schema.names(), query_marches (déjà importés)

  • Step 1: Mettre à jour l'import de src.figures

Dans src/pages/acheteur.py, remplacer :

from src.figures import (
    DataTable,
    get_distance_histogram,
    get_top_org_table,
    make_card,
    make_column_picker,
    point_on_map,
)

par :

from src.figures import (
    DataTable,
    get_distance_histogram,
    get_org_location_map,
    get_top_org_table,
    make_card,
    make_column_picker,
)
  • Step 2: Retirer la construction de la carte de update_acheteur_infos

Remplacer le callback existant (celui avec les Output acheteur_siret, acheteur_nom, acheteur_commune, acheteur_map, acheteur_departement, acheteur_region, acheteur_lien_annuaire) :

@callback(
    Output(component_id="acheteur_siret", component_property="children"),
    Output(component_id="acheteur_nom", component_property="children"),
    Output(component_id="acheteur_commune", component_property="children"),
    Output(component_id="acheteur_map", component_property="children"),
    Output(component_id="acheteur_departement", component_property="children"),
    Output(component_id="acheteur_region", component_property="children"),
    Output(component_id="acheteur_lien_annuaire", component_property="href"),
    Input(component_id="acheteur_url", component_property="pathname"),
)
def update_acheteur_infos(url):
    acheteur_siret = url.split("/")[-1]
    # if len(acheteur_siret) != 14:
    #     acheteur_siret = (
    #         f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
    #     )
    data = get_annuaire_data(acheteur_siret)
    data_etablissement = data.get("matching_etablissements") if data else None
    if data_etablissement:
        data_etablissement = data_etablissement[0]

        # Extraction du code département à partir du code postal
        code_postal = data_etablissement.get("code_postal", "")
        departement_code = code_postal[:2] if code_postal else None

        # Création de la carte avec le code département pour un centrage approprié
        acheteur_map = point_on_map(
            data_etablissement["latitude"],
            data_etablissement["longitude"],
            departement_code,
        )
        code_departement, nom_departement, nom_region = get_departement_region(
            data_etablissement["code_postal"]
        )
        departement = f"{nom_departement} ({code_departement})"
        lien_annuaire = (
            f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
        )
        raison_sociale = data["nom_raison_sociale"]
        libelle_commune = data_etablissement["libelle_commune"]

    else:
        acheteur_map = html.Div()
        code_departement, nom_departement, nom_region = "", "", ""
        departement = ""
        lien_annuaire = ""
        raison_sociale = ""
        libelle_commune = ""

    return (
        acheteur_siret,
        raison_sociale,
        libelle_commune,
        acheteur_map,
        departement,
        nom_region,
        lien_annuaire,
    )

par :

@callback(
    Output(component_id="acheteur_siret", component_property="children"),
    Output(component_id="acheteur_nom", component_property="children"),
    Output(component_id="acheteur_commune", component_property="children"),
    Output(component_id="acheteur_departement", component_property="children"),
    Output(component_id="acheteur_region", component_property="children"),
    Output(component_id="acheteur_lien_annuaire", component_property="href"),
    Input(component_id="acheteur_url", component_property="pathname"),
)
def update_acheteur_infos(url):
    acheteur_siret = url.split("/")[-1]
    data = get_annuaire_data(acheteur_siret)
    data_etablissement = data.get("matching_etablissements") if data else None
    if data_etablissement:
        data_etablissement = data_etablissement[0]

        code_departement, nom_departement, nom_region = get_departement_region(
            data_etablissement["code_postal"]
        )
        departement = f"{nom_departement} ({code_departement})"
        lien_annuaire = (
            f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
        )
        raison_sociale = data["nom_raison_sociale"]
        libelle_commune = data_etablissement["libelle_commune"]

    else:
        code_departement, nom_departement, nom_region = "", "", ""
        departement = ""
        lien_annuaire = ""
        raison_sociale = ""
        libelle_commune = ""

    return (
        acheteur_siret,
        raison_sociale,
        libelle_commune,
        departement,
        nom_region,
        lien_annuaire,
    )


@callback(
    Output(component_id="acheteur_map", component_property="children"),
    Input(component_id="acheteur_url", component_property="pathname"),
    Input(component_id="acheteur_year", component_property="value"),
)
def update_acheteur_map(pathname, ach_year):
    where_sql, params = _acheteur_scope(pathname, ach_year)
    geo_columns = [
        col
        for col in [
            "uid",
            "acheteur_longitude",
            "acheteur_latitude",
            "acheteur_nom",
            "titulaire_longitude",
            "titulaire_latitude",
            "titulaire_nom",
        ]
        if col in schema.names()
    ]
    dff = query_marches(where_sql, params, columns=geo_columns)
    return get_org_location_map(dff, "acheteur", "acheteur_map_leaflet")
  • Step 3: Vérifier qu'il n'y a plus de référence à point_on_map dans le fichier

Run: grep -n "point_on_map" src/pages/acheteur.py Expected: aucune sortie

  • Step 4: Lancer la suite de tests complète

Run: uv run pytest Expected: tous les tests passent

  • Step 5: Vérification manuelle dans le navigateur

Run: python run.py (dans un terminal séparé, avec le .venv activé et un .env pointant vers un jeu de données de production contenant des colonnes longitude/latitude)

Naviguer vers /acheteurs/<un-siret-existant> :

  • La carte doit afficher des marqueurs orange (acheteur) et bleus (titulaires), clusterisés.
  • Les boutons de zoom +/- doivent être visibles sur la carte.
  • Changer le filtre Année doit mettre à jour la carte.

Arrêter le serveur (Ctrl+C) une fois la vérification faite.

  • Step 6: Commit
git add src/pages/acheteur.py
git commit -m "$(cat <<'EOF'
feat(acheteur): afficher les titulaires sur la carte de la fiche acheteur

EOF
)"

Task 4: Brancher la carte sur /titulaires/<id> et supprimer point_on_map

Files:

  • Modify: src/pages/titulaire.py
  • Modify: src/figures.py:184-254 (suppression de point_on_map)

Interfaces:

  • Consumes: get_org_location_map (Task 2), _titulaire_scope(pathname, titulaire_year) -> tuple[str, list] (déjà existant, src/pages/titulaire.py:49)

  • Step 1: Mettre à jour l'import de src.figures

Dans src/pages/titulaire.py, remplacer :

from src.figures import (
    DataTable,
    get_distance_histogram,
    get_top_org_table,
    make_column_picker,
    point_on_map,
)

par :

from src.figures import (
    DataTable,
    get_distance_histogram,
    get_org_location_map,
    get_top_org_table,
    make_column_picker,
)
  • Step 2: Retirer la construction de la carte de update_titulaire_infos

Remplacer le callback existant (celui avec les Output titulaire_siret, titulaire_nom, titulaire_commune, titulaire_map, titulaire_departement, titulaire_region, titulaire_lien_annuaire, titulaire_activite_libelle) :

@callback(
    Output(component_id="titulaire_siret", component_property="children"),
    Output(component_id="titulaire_nom", component_property="children"),
    Output(component_id="titulaire_commune", component_property="children"),
    Output(component_id="titulaire_map", component_property="children"),
    Output(component_id="titulaire_departement", component_property="children"),
    Output(component_id="titulaire_region", component_property="children"),
    Output(component_id="titulaire_lien_annuaire", component_property="href"),
    Output(component_id="titulaire_activite_libelle", component_property="children"),
    Input(component_id="titulaire_url", component_property="pathname"),
)
def update_titulaire_infos(url):
    titulaire_siret = url.split("/")[-1]
    if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
        activite_libelle_row = DF_TITULAIRES.filter(
            pl.col("titulaire_id") == titulaire_siret
        ).select("titulaire_activite_libelle")
        activite_libelle = (
            activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
        )
    else:
        activite_libelle = ""
    data = get_annuaire_data(titulaire_siret)
    data_etablissement = data.get("matching_etablissements") if data else None
    if data_etablissement:
        data_etablissement = data_etablissement[0]

        # Extraction du code département à partir du code postal
        code_postal = data_etablissement.get("code_postal", "")
        departement_code = code_postal[:2] if code_postal else None

        # Création de la carte avec le code département pour un centrage approprié
        titulaire_map = point_on_map(
            data_etablissement["latitude"],
            data_etablissement["longitude"],
            departement_code,
        )
        code_departement, nom_departement, nom_region = get_departement_region(
            data_etablissement["code_postal"]
        )
        departement = f"{nom_departement} ({code_departement})"
        lien_annuaire = (
            f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
        )
        raison_sociale = data["nom_raison_sociale"]
        libelle_commune = data_etablissement["libelle_commune"]

    else:
        titulaire_map = html.Div()
        code_departement, nom_departement, nom_region = "", "", ""
        departement = ""
        lien_annuaire = ""
        raison_sociale = html.Span(
            f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
        )
        libelle_commune = ""

    return (
        titulaire_siret,
        raison_sociale,
        libelle_commune,
        titulaire_map,
        departement,
        nom_region,
        lien_annuaire,
        activite_libelle,
    )

par :

@callback(
    Output(component_id="titulaire_siret", component_property="children"),
    Output(component_id="titulaire_nom", component_property="children"),
    Output(component_id="titulaire_commune", component_property="children"),
    Output(component_id="titulaire_departement", component_property="children"),
    Output(component_id="titulaire_region", component_property="children"),
    Output(component_id="titulaire_lien_annuaire", component_property="href"),
    Output(component_id="titulaire_activite_libelle", component_property="children"),
    Input(component_id="titulaire_url", component_property="pathname"),
)
def update_titulaire_infos(url):
    titulaire_siret = url.split("/")[-1]
    if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
        activite_libelle_row = DF_TITULAIRES.filter(
            pl.col("titulaire_id") == titulaire_siret
        ).select("titulaire_activite_libelle")
        activite_libelle = (
            activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
        )
    else:
        activite_libelle = ""
    data = get_annuaire_data(titulaire_siret)
    data_etablissement = data.get("matching_etablissements") if data else None
    if data_etablissement:
        data_etablissement = data_etablissement[0]

        code_departement, nom_departement, nom_region = get_departement_region(
            data_etablissement["code_postal"]
        )
        departement = f"{nom_departement} ({code_departement})"
        lien_annuaire = (
            f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
        )
        raison_sociale = data["nom_raison_sociale"]
        libelle_commune = data_etablissement["libelle_commune"]

    else:
        code_departement, nom_departement, nom_region = "", "", ""
        departement = ""
        lien_annuaire = ""
        raison_sociale = html.Span(
            f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
        )
        libelle_commune = ""

    return (
        titulaire_siret,
        raison_sociale,
        libelle_commune,
        departement,
        nom_region,
        lien_annuaire,
        activite_libelle,
    )


@callback(
    Output(component_id="titulaire_map", component_property="children"),
    Input(component_id="titulaire_url", component_property="pathname"),
    Input(component_id="titulaire_year", component_property="value"),
)
def update_titulaire_map(pathname, titulaire_year):
    where_sql, params = _titulaire_scope(pathname, titulaire_year)
    geo_columns = [
        col
        for col in [
            "uid",
            "acheteur_longitude",
            "acheteur_latitude",
            "acheteur_nom",
            "titulaire_longitude",
            "titulaire_latitude",
            "titulaire_nom",
        ]
        if col in schema.names()
    ]
    dff = query_marches(where_sql, params, columns=geo_columns)
    return get_org_location_map(dff, "titulaire", "titulaire_map_leaflet")
  • Step 3: Supprimer point_on_map (devenue inutilisée) de src/figures.py

Vérifier d'abord qu'il n'y a plus aucun appelant :

Run: grep -rn "point_on_map" src/ Expected: aucune sortie

Puis supprimer la fonction complète dans src/figures.py (actuellement lignes 184-254, entre def point_on_map(lat, lon, departement_code=None): et le return html.Div(...) qui la clôt, juste avant class DataTable(dash_table.DataTable):) :

def point_on_map(lat, lon, departement_code=None):
    """Fonction améliorée utilisant les codes départementaux pour la détection de région.

    Args:
        lat: Coordonnée de latitude
        lon: Coordonnée de longitude
        departement_code: Code du département (ex: '75', '971', etc.)

    Returns:
        html.Div contenant la carte, ou div vide si invalide
    """
    # Validation des coordonnées
    try:
        lat = float(lat)
        lon = float(lon)
    except (TypeError, ValueError):
        return html.Div()  # Div vide pour les coordonnées invalides

    # Vérification que les coordonnées sont valides
    if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
        return html.Div()

    # Si aucun code département n'est fourni, retourner une div vide
    if not departement_code:
        return html.Div()

    # Détermination de la région en utilisant le code département
    # Logique identique à get_geographic_maps
    if departement_code in ["971", "972", "973", "974", "976"]:
        region_key = departement_code  # Département d'outre-mer
    elif len(departement_code) == 2:  # Département métropolitain
        region_key = "Hexagone"
    else:
        return html.Div()  # Format de code département invalide

    # Paramètres de carte par région (réutilisés de get_geographic_maps)
    regions = {
        "Hexagone": {"center": [46.6, 2.2], "zoom": 5},
        "971": {"center": [16.23, -61.55], "zoom": 9},  # Guadeloupe
        "972": {"center": [14.64, -61.02], "zoom": 10},  # Martinique
        "973": {"center": [3.93, -53.12], "zoom": 7},  # Guyane
        "974": {"center": [-21.11, 55.53], "zoom": 9},  # La Réunion
        "976": {"center": [-12.82, 45.16], "zoom": 10},  # Mayotte
    }

    settings = regions.get(region_key, regions["Hexagone"])

    # Création de la carte
    fig = px.scatter_map(
        lat=[lat],
        lon=[lon],
        height=300,
        # width=400,
        color=[1],
        zoom=settings["zoom"],
    )

    fig.update_traces(marker=dict(size=10))

    # Configuration de la carte (interactive - zoomable)
    fig.update_layout(
        map_style="light",  # Fond de carte clair
        margin={"r": 0, "t": 0, "l": 0, "b": 0},
        mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
        mapbox_zoom=settings["zoom"],
        coloraxis_showscale=False,
    )

    return html.Div(
        dcc.Graph(figure=fig, config={"displayModeBar": False}),
    )

Supprimer l'intégralité de ce bloc (la ligne vide qui le sépare de class DataTable peut rester telle quelle).

  • Step 4: Lancer la suite de tests complète

Run: uv run pytest Expected: tous les tests passent

  • Step 5: Vérification manuelle dans le navigateur

Run: python run.py (avec un jeu de données de production contenant des colonnes longitude/latitude)

Naviguer vers /titulaires/<un-siret-existant> :

  • La carte doit afficher un marqueur bleu (titulaire) et des marqueurs orange (acheteurs), clusterisés.
  • Les boutons de zoom +/- doivent être visibles.
  • Changer le filtre Année doit mettre à jour la carte.

Arrêter le serveur (Ctrl+C) une fois la vérification faite.

  • Step 6: Commit
git add src/pages/titulaire.py src/figures.py
git commit -m "$(cat <<'EOF'
feat(titulaire): afficher les acheteurs sur la carte de la fiche titulaire

Supprime point_on_map, devenue inutilisée après migration des deux pages
vers get_org_location_map.
EOF
)"