# Migration AG Grid — `acheteur.py` + `titulaire.py` (Lot 2a) — 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:** Remplacer les `dash_table.DataTable` des pages `/acheteurs/<id>` et `/titulaires/<id>` par des grilles `dash-ag-grid`, en factorisant la logique commune dans un module partagé et en réutilisant au maximum l'infrastructure AG Grid du Lot 1 (`tableau.py`).

**Architecture:** Un nouveau module `src/utils/entity_grid.py` porte toute la logique de grille scopée à une entité (fonctions pures testables + une fabrique de callbacks appelée une fois par page). Chaque grille a un `id` **pattern-matching** (`{"type": "<org>-grid", "entity_id": ..., "year": ...}`) pour scoper la persistance du `filterModel` par fiche ; la disposition des colonnes (`columnState`) est persistée globalement dans un `dcc.Store` partagé. Le datasource, le reset, l'export filtré et le compteur passent par le moteur AST → SQL DuckDB du Lot 1 (`fetch_grid_page`, `export_dataframe`, `grid_column_defs`, `apply_persisted_layout`).

**Tech Stack:** Dash 4.4, dash-ag-grid 35.2.0, DuckDB, Polars, pytest + DashComposite/Selenium.

**Spec de référence:** `docs/superpowers/specs/2026-07-13-migration-ag-grid-acheteur-titulaire-design.md`

## Global Constraints

- Périmètre = **`acheteur.py` + `titulaire.py` uniquement**. Ne pas toucher `observatoire.py` (Lot 2b), `recherche.py`, `admin/liste.py`, `figures.make_table` (Lot 3), ni `tableau.py` (déjà migré — ses appels doivent rester fonctionnellement inchangés).
- **Dash 4.4** : `Input`/`State` en `MATCH` avec `Output` à id fixe est autorisé (relaxation Dash 4.2+). Un `Output` en `MATCH` exige toujours un `Input`/`State` en `MATCH` sur la même clé ; pour piloter la grille depuis un bouton à id fixe, utiliser `ALL` en `Output`/`State` (une seule grille présente à la fois).
- Imports internes toujours préfixés `src.` (ex. `src.utils.entity_grid`).
- **Thème "brique" du Lot 1 réutilisé** via `ag_grid()` (pas d'apparence de base non-thémée, pas de nouveau CSS).
- **Extensions rétro-compatibles du Lot 1** : `ag_grid()` et `export_dataframe()` gagnent des paramètres optionnels ; les appels existants de `tableau.py` doivent continuer à marcher **sans modification** (valeurs par défaut = comportement actuel).
- Sécurité : identifiants de colonnes validés contre `schema.names()` (déjà fait par `ast_to_sql`/`grid_column_defs`), valeurs **toujours** liées via `?` (jamais concaténées). Le scope entité (`entity_id`, année) passe **toujours** en paramètre lié, jamais concaténé dans le SQL.
- Lancer `pre-commit run --files <fichiers>` avant chaque `git add`/commit (hook prettier/ruff peut reformater — re-`git add` puis committer).
- Tests par fichier pendant le lot ; `uv run pytest` complet **uniquement** à la dernière tâche (cf. mémoire projet « full suite only at end »).
- Ne pas supprimer `filter_table_data`/`sort_table_data`/`prepare_table_data`/`clean_filters`/`filter_query_to_sql` : encore utilisés par `observatoire.py` et les Lots suivants. On les **débranche** seulement d'acheteur/titulaire.
- **Hors périmètre, à ne PAS migrer** : le sélecteur de colonnes (`make_column_picker`, composant `{page}_column_list`) reste un `dash_table.DataTable` — il est partagé avec `tableau.py` (lui aussi non migré sur ce point) ; le migrer toucherait `tableau.py` et sort du lot.

**Fonctions/objets réutilisables (ne pas réécrire) :**

- `src/db.py` : `schema` (pl.Schema), `query_marches(where_sql, params, columns, order_by, limit, offset)`, `count_marches(where_sql, params)`, `count_unique_marches(where_sql, params)`, `aggregate_marches(select_sql, where_sql, params, ...)`.
- `src/utils/grid.py` : `fetch_grid_page(filter_model, sort_model, start_row, end_row, base_where_sql="TRUE", base_params=()) -> (rows, total, total_unique)`, `grid_column_defs(hidden_columns=None)`, `apply_persisted_layout(defs, column_state)`, `export_dataframe(...)` (étendu en Task 1).
- `src/utils/table.py` : `postprocess_page(dff)`, `setup_table_columns(dff, hideable=True, exclude=None) -> (columns, tooltip)`, `add_links(dff)`, `get_default_hidden_columns(page)`, `write_styled_excel(df, buffer, worksheet="DECP")`, `COLUMNS` (= `schema.names()`).
- `src/figures.py` : `ag_grid(grid_id, column_defs, ...)` (paramétré en Task 2), `AG_GRID_LOCALE_FR`, `make_column_picker(page)`, `make_card(title, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4)`, `DATA_SCHEMA`.
- `src/utils/frontend.py` : `get_button_properties(count) -> (disabled, children, title)` (état du bouton de téléchargement selon le seuil de 65 000 lignes), `DROPDOWN_LABELS_FR`.
- `src/utils/tracking.py` : `track_search(query, source)`.

---

## File Structure

- **Modify** `src/utils/grid.py` — étendre `export_dataframe()` avec `base_where_sql`/`base_params`.
- **Modify** `src/figures.py` — paramétrer `ag_grid()` (id dict + `persisted_props`) ; ajouter `get_top_org_ag_grid()`.
- **Create** `src/utils/entity_grid.py` — fonctions pures de grille scopée + `register_entity_grid_callbacks(org_type)`.
- **Create** `tests/test_entity_grid.py` — tests unitaires des fonctions pures.
- **Modify** `src/pages/acheteur.py` — `layout` → fonction, grille AG Grid via `entity_grid`, top 10 en AG Grid, suppression des callbacks DataTable.
- **Modify** `src/pages/titulaire.py` — idem acheteur.
- **Modify** `tests/test_main.py` — sélecteurs AG Grid (`test_002_filter_persistence`) ; vérifier `test_003_tableau_download`.

---

## Task 1: Étendre `export_dataframe()` avec le scope de base

**Files:**

- Modify: `src/utils/grid.py:71-85`
- Test: `tests/test_grid.py`

**Interfaces:**

- Produces: `export_dataframe(filter_model, sort_model, hidden_columns, base_where_sql="TRUE", base_params=()) -> pl.DataFrame`

- [ ] **Step 1: Écrire le test qui échoue**

Ajouter dans `tests/test_grid.py` :

```python
def test_export_dataframe_applies_base_scope():
    """base_where_sql restreint l'export à un sous-ensemble (ex. un acheteur),
    en plus du filterModel — utilisé par acheteur.py/titulaire.py (#41)."""
    # Récupère un acheteur_id présent dans le jeu de test.
    unscoped = export_dataframe(None, None, hidden_columns=[])
    assert unscoped.height > 0
    an_acheteur = unscoped["acheteur_id"][0]

    scoped = export_dataframe(
        None,
        None,
        hidden_columns=[],
        base_where_sql="acheteur_id = ?",
        base_params=(an_acheteur,),
    )
    assert scoped.height > 0
    assert scoped.height <= unscoped.height
    assert set(scoped["acheteur_id"].to_list()) == {an_acheteur}
```

- [ ] **Step 2: Lancer le test, vérifier l'échec**

Run: `uv run pytest tests/test_grid.py::test_export_dataframe_applies_base_scope -v`
Expected: FAIL — `TypeError: export_dataframe() got an unexpected keyword argument 'base_where_sql'`.

- [ ] **Step 3: Implémenter l'extension**

Remplacer `export_dataframe` dans `src/utils/grid.py` par :

```python
def export_dataframe(
    filter_model,
    sort_model,
    hidden_columns,
    base_where_sql: str = "TRUE",
    base_params: tuple = (),
) -> pl.DataFrame:
    """Renvoie les lignes filtrées/triées pour l'export Excel.

    Colonnes masquées exclues, valeurs brutes (non post-traitées HTML).
    `base_where_sql`/`base_params` scopent l'export à un sous-ensemble (ex. un
    acheteur/titulaire) — combinés en `(base) AND (filtre)`, comme
    `fetch_grid_page`. Par défaut `TRUE` → comportement inchangé pour tableau.py.
    """
    ast = filtermodel_to_ast(filter_model, schema)
    filter_sql, filter_params = ast_to_sql(ast, schema)
    where_sql = f"({base_where_sql}) AND ({filter_sql})"
    params = [*base_params, *filter_params]
    order_by = sort_model_to_sql(sort_model, schema) or None
    visible = [c for c in schema.names() if c not in set(hidden_columns or [])]
    return query_marches(
        where_sql=where_sql,
        params=params,
        columns=visible,
        order_by=order_by,
    )
```

- [ ] **Step 4: Lancer les tests du fichier, vérifier le succès + non-régression**

Run: `uv run pytest tests/test_grid.py -v`
Expected: PASS pour tous (dont `test_export_dataframe_excludes_hidden_columns` et `test_export_dataframe_applies_filter`, qui appellent sans les nouveaux args → défaut `TRUE`).

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/utils/grid.py tests/test_grid.py
git add src/utils/grid.py tests/test_grid.py
git commit -m "feat(grid): export_dataframe scopable par base_where_sql/base_params (#41)"
```

---

## Task 2: Paramétrer `ag_grid()` (id dict + persisted_props)

**Files:**

- Modify: `src/figures.py:1101-1162`
- Test: `tests/test_figures.py`

**Interfaces:**

- Produces: `ag_grid(grid_id: str | dict, column_defs: list[dict], persisted_props=("filterModel", "columnState"), persistence=True) -> dag.AgGrid`

- [ ] **Step 1: Écrire les tests qui échouent**

Ajouter dans `tests/test_figures.py` :

```python
def test_ag_grid_accepts_dict_id_and_custom_persisted_props():
    """Grille entité : id pattern-matching + persistance limitée au filterModel
    (columnState géré via un store partagé, pas la persistance native)."""
    from src.figures import ag_grid

    gid = {"type": "acheteur-grid", "entity_id": "123", "year": "Toutes les années"}
    grid = ag_grid(gid, [], persisted_props=["filterModel"])

    assert grid.id == gid
    assert list(grid.persisted_props) == ["filterModel"]
    assert grid.persistence is True


def test_ag_grid_defaults_unchanged_for_tableau():
    """Sans args de persistance, comportement identique au Lot 1 (tableau.py)."""
    from src.figures import ag_grid

    grid = ag_grid("tableau_grid", [])

    assert grid.id == "tableau_grid"
    assert list(grid.persisted_props) == ["filterModel", "columnState"]
    assert grid.persistence is True
```

- [ ] **Step 2: Lancer les tests, vérifier l'échec**

Run: `uv run pytest tests/test_figures.py::test_ag_grid_accepts_dict_id_and_custom_persisted_props -v`
Expected: FAIL — `TypeError: ag_grid() got an unexpected keyword argument 'persisted_props'`.

- [ ] **Step 3: Implémenter la paramétrisation**

Dans `src/figures.py`, modifier la signature et les deux lignes de persistance de `ag_grid()` :

```python
def ag_grid(
    grid_id: "str | dict",
    column_defs: list[dict],
    persisted_props=("filterModel", "columnState"),
    persistence: bool = True,
) -> "dag.AgGrid":
```

Et, dans le `return dag.AgGrid(...)`, remplacer les lignes finales :

```python
        persistence=persistence,
        persistence_type="local",
        persisted_props=list(persisted_props),
    )
```

(Le reste du corps — `dashGridOptions`, thème, `style`, etc. — est inchangé. `id=grid_id` accepte déjà str ou dict.)

- [ ] **Step 4: Lancer les tests, vérifier le succès + non-régression**

Run: `uv run pytest tests/test_figures.py -v`
Expected: PASS pour tous (dont les tests existants `test_ag_grid_locale_text_*`, `test_ag_grid_keeps_base_appearance`, `test_ag_grid_always_shows_horizontal_scroll`).

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/figures.py tests/test_figures.py
git add src/figures.py tests/test_figures.py
git commit -m "feat(figures): ag_grid() paramétrable (id dict + persisted_props) (#41)"
```

---

## Task 3: `get_top_org_ag_grid()` — tableau « top 10 » en AG Grid

**Files:**

- Modify: `src/figures.py` (ajouter la fonction près de `get_top_org_table`, ~ligne 1062)
- Test: `tests/test_figures.py`

**Interfaces:**

- Consumes: `setup_table_columns`, `add_links` (src.utils.table) ; `ag_grid` (Task 2).
- Produces: `get_top_org_ag_grid(data, org_type, extra_columns, filters=True) -> dag.AgGrid | html.Div` — même rôle que `get_top_org_table`, mais rend une AG Grid client-side (row model par défaut, `rowData` fournie). Renvoie `html.Div()` si vide/erreur.

- [ ] **Step 1: Écrire les tests qui échouent**

Ajouter dans `tests/test_figures.py` :

```python
def test_get_top_org_ag_grid_returns_grid_with_rowdata():
    """Le top 10 devient une AG Grid client-side (rowData directe, pas de
    getRowsRequest server-side)."""
    import dash_ag_grid as dag

    from src.db import query_marches
    from src.figures import get_top_org_ag_grid

    lff = query_marches("TRUE", (), columns=None).lazy()
    grid = get_top_org_ag_grid(lff, "titulaire", ["titulaire_distance"])

    assert isinstance(grid, dag.AgGrid)
    assert grid.rowData is not None and len(grid.rowData) > 0
    # Colonne d'agrégat présente ; colonnes définies.
    assert any(c.get("field") == "Attributions" for c in grid.columnDefs)
    # Pas de persistance (petit tableau statique, régénéré à chaque fiche).
    assert grid.persistence in (False, None)


def test_get_top_org_ag_grid_empty_returns_div():
    """Données vides → html.Div() (parité get_top_org_table)."""
    import polars as pl
    from dash import html

    from src.figures import get_top_org_ag_grid

    empty = pl.DataFrame(
        {"uid": [], "titulaire_id": [], "titulaire_nom": [], "titulaire_distance": []}
    ).lazy()
    out = get_top_org_ag_grid(empty, "titulaire", ["titulaire_distance"])
    assert isinstance(out, html.Div)


def test_get_top_org_table_still_returns_datatable_after_refactor():
    """Non-régression : get_top_org_table (utilisé par observatoire.py) garde
    son comportement après extraction du helper d'agrégation partagé."""
    import dash_ag_grid  # noqa: F401
    from dash import html

    from src.db import query_marches
    from src.figures import DataTable, get_top_org_table

    lff = query_marches("TRUE", (), columns=None).lazy()
    out = get_top_org_table(lff, "titulaire", ["titulaire_distance"])
    assert isinstance(out, (DataTable, html.Div))


def test_top_org_aggregate_does_not_mutate_extra_columns():
    """Le helper ne mute pas la liste extra_columns passée (bug latent de
    l'ancien get_top_org_table, qui faisait extra_columns.append(...))."""
    from src.db import query_marches
    from src.figures import _top_org_aggregate

    extra = ["titulaire_distance"]
    _top_org_aggregate(query_marches("TRUE", (), columns=None).lazy(), "titulaire", extra)
    assert extra == ["titulaire_distance"]
```

- [ ] **Step 2: Lancer les tests, vérifier l'échec**

Run: `uv run pytest tests/test_figures.py::test_get_top_org_ag_grid_returns_grid_with_rowdata -v`
Expected: FAIL — `ImportError: cannot import name 'get_top_org_ag_grid'`.

- [ ] **Step 3a: Extraire le helper d'agrégation partagé**

Dans `src/figures.py`, ajouter le helper `_top_org_aggregate` (juste avant `get_top_org_table`). Il porte l'agrégation commune ; il **copie** `extra_columns` (l'ancien `get_top_org_table` mutait la liste du caller — bug latent corrigé) et renvoie le `pl.DataFrame` agrégé **avant** `add_links`, ou `None` si vide/erreur :

```python
def _top_org_aggregate(data, org_type: str, extra_columns: list):
    """Agrégation « top N » commune à get_top_org_table et get_top_org_ag_grid.

    Renvoie le DataFrame agrégé (colonnes castées en str, tri par Attributions
    décroissant), AVANT add_links, ou None si vide/colonne manquante.
    """
    if isinstance(data, pl.LazyFrame):
        lff = data
    else:
        lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)

    extra = list(extra_columns)  # copie : ne pas muter la liste du caller
    if org_type == "titulaire":
        extra.append("titulaire_typeIdentifiant")
    columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra

    lff = lff.select(columns)
    lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra).agg(
        pl.len().alias("Attributions")
    )
    lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
    lff = lff.cast(pl.String)
    lff = lff.fill_null("")

    try:
        dff: pl.DataFrame = lff.collect(engine="streaming")
    except ColumnNotFoundError:
        logger.warning(f"_top_org_aggregate: column not found. {lff.collect_schema()}")
        return None
    return dff if dff.height > 0 else None
```

Puis **refactorer `get_top_org_table`** pour l'utiliser (comportement identique : `setup_table_columns` toujours appelé sur le dff _avant_ `add_links`). Remplacer le début de son corps (jusqu'au `dff = add_links(dff)` inclus) par :

```python
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
    dff = _top_org_aggregate(data, org_type, extra_columns)
    if dff is None:
        return html.Div()

    columns, tooltip = setup_table_columns(
        dff, hideable=False, exclude=[f"{org_type}_id"]
    )
    dff = add_links(dff)
    data = dff.to_dicts()

    return DataTable(
        dtid=f"top10_{org_type}",
        data=data,
        page_action="native",
        page_size=10,
        columns=columns,
        tooltip_header=tooltip,
        filter_action="native" if filters else "none",
    )
```

- [ ] **Step 3b: Implémenter `get_top_org_ag_grid` via le helper**

Ajouter (juste après `get_top_org_table`) :

```python
def get_top_org_ag_grid(data, org_type: str, extra_columns: list, filters: bool = True):
    """Top N acheteurs/titulaires en AG Grid client-side (rowData directe).

    Remplace get_top_org_table (dash_table) : même agrégation (helper partagé),
    rendu AG Grid (thème brique, liens markdown). html.Div() si vide/erreur.
    """
    dff = _top_org_aggregate(data, org_type, extra_columns)
    if dff is None:
        return html.Div()

    dff = add_links(dff)

    # columnDefs : on masque l'id (lien porté par le nom), on rend le nom en
    # markdown (HTML <a>), on cache les colonnes techniques *_tooltip.
    id_col = f"{org_type}_id"
    nom_col = f"{org_type}_nom"
    link_cols = {nom_col}
    column_defs = []
    for col in dff.columns:
        if col == id_col or col.endswith("_tooltip"):
            continue
        col_def = {
            "field": col,
            "headerName": DATA_SCHEMA.get(col, {}).get("title", col),
            "sortable": True,
            "filter": "agTextColumnFilter" if filters else False,
        }
        if col in link_cols:
            col_def["cellRenderer"] = "markdown"
            col_def["flex"] = 1
        column_defs.append(col_def)

    # ag_grid() du Lot 1 est server-side (rowModelType="infinite", pas de
    # rowData) : inadapté au top 10, qui est client-side. On construit donc une
    # AG Grid client-side directe, en réutilisant le thème + localeText.
    return dag.AgGrid(
        id=f"top10_{org_type}",
        columnDefs=column_defs,
        rowData=dff.to_dicts(),
        dangerously_allow_code=True,  # rend le HTML <a> des cellules liens
        columnSize="responsiveSizeToFit",
        dashGridOptions={
            "domLayout": "autoHeight",  # OK ici : row model client-side
            "pagination": True,
            "paginationPageSize": 10,
            "suppressCellFocus": True,
            "localeText": AG_GRID_LOCALE_FR,
            "theme": {
                "function": (
                    "themeQuartz.withParams({"
                    "accentColor: 'rgb(179, 56, 33)',"
                    "headerTextColor: 'white',"
                    "headerBackgroundColor: 'rgb(179, 56, 33)',"
                    "oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
                    "borderColor: '#ccc',"
                    "fontFamily: 'Inter, sans-serif',"
                    "fontSize: 16"
                    "})"
                )
            },
        },
        style={"width": "100%"},
        persistence=False,
    )
```

(Vérifier que `import dash_ag_grid as dag` est déjà présent en tête de `src/figures.py` — c'est le cas. `add_links`, `setup_table_columns`, `ColumnNotFoundError`, `DATA_SCHEMA`, `logger`, `AG_GRID_LOCALE_FR` sont déjà importés/définis dans `src/figures.py` puisque `get_top_org_table` les utilise.)

> `get_top_org_table` reste en place ce lot (encore utilisé par `observatoire.py`), mais partage désormais l'agrégation via `_top_org_aggregate` (plus de duplication). Le test de non-régression garantit qu'il rend toujours une `DataTable`.

- [ ] **Step 4: Lancer les tests, vérifier le succès**

Run: `uv run pytest tests/test_figures.py -k "top_org" -v`
Expected: PASS pour `test_get_top_org_ag_grid_returns_grid_with_rowdata`, `test_get_top_org_ag_grid_empty_returns_div`, `test_get_top_org_table_still_returns_datatable_after_refactor`, `test_top_org_aggregate_does_not_mutate_extra_columns`.

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/figures.py tests/test_figures.py
git add src/figures.py tests/test_figures.py
git commit -m "feat(figures): get_top_org_ag_grid (top 10 en AG Grid client-side) (#41)"
```

---

## Task 4: `entity_grid.py` — fonctions pures de grille scopée

**Files:**

- Create: `src/utils/entity_grid.py`
- Test: `tests/test_entity_grid.py`

**Interfaces:**

- Consumes: `fetch_grid_page`, `export_dataframe` (Task 1), `grid_column_defs`, `apply_persisted_layout` (grid.py) ; `ag_grid` (Task 2) ; `schema` (src.db).
- Produces (consommées en Task 5/6/7) :

  - `entity_scope(org_type: str, entity_id: str, year) -> tuple[str, list]`
  - `entity_grid_column_defs(hidden_columns, column_state) -> list[dict]`
  - `fetch_entity_page(org_type, entity_id, year, request: dict) -> tuple[dict, int, int]` — renvoie `({"rowData": ..., "rowCount": total}, total, total_unique)`
  - `export_entity_dataframe(org_type, entity_id, year, filter_model, column_state) -> pl.DataFrame`
  - `clear_sort(column_state) -> list[dict]`
  - `build_entity_grid(org_type, entity_id, year, hidden_columns, column_state) -> dag.AgGrid`
  - `GRID_TYPE = "{org_type}-grid"` construit via `grid_type(org_type)`

- [ ] **Step 1: Écrire les tests qui échouent**

Créer `tests/test_entity_grid.py` :

```python
import dash_ag_grid as dag
import pytest

import src.app  # noqa: F401  # instancie l'app → schema chargé
from src.db import query_marches
from src.utils.entity_grid import (
    build_entity_grid,
    clear_sort,
    entity_grid_column_defs,
    entity_scope,
    export_entity_dataframe,
    fetch_entity_page,
    grid_type,
)


def _an_acheteur_id():
    return query_marches("TRUE", (), columns=["acheteur_id"])["acheteur_id"][0]


def test_entity_scope_acheteur():
    where, params = entity_scope("acheteur", "S123", None)
    assert where == "acheteur_id = ?"
    assert params == ["S123"]


def test_entity_scope_titulaire_requires_siret():
    where, params = entity_scope("titulaire", "S123", None)
    assert "titulaire_id = ?" in where
    assert "titulaire_typeIdentifiant = 'SIRET'" in where
    assert params == ["S123"]


def test_entity_scope_with_year_adds_bound_param():
    where, params = entity_scope("acheteur", "S123", "2025")
    assert 'YEAR("dateNotification") = ?' in where
    assert params == ["S123", 2025]


def test_entity_scope_toutes_les_annees_no_year_filter():
    where, params = entity_scope("acheteur", "S123", "Toutes les années")
    assert "YEAR" not in where
    assert params == ["S123"]


def test_fetch_entity_page_is_scoped():
    ach = _an_acheteur_id()
    request = {"startRow": 0, "endRow": 50, "filterModel": None, "sortModel": None}
    response, total, total_unique = fetch_entity_page("acheteur", ach, None, request)
    assert total > 0
    assert total_unique > 0
    assert response["rowCount"] == total
    assert len(response["rowData"]) <= 50
    # Toutes les lignes appartiennent à l'acheteur scopé.
    assert all(str(r["acheteur_id"]).find(str(ach)) != -1 for r in response["rowData"])


def test_fetch_entity_page_none_request():
    from dash import no_update

    out = fetch_entity_page("acheteur", "S123", None, None)
    assert out == (no_update, no_update, no_update)


def test_export_entity_dataframe_scoped():
    ach = _an_acheteur_id()
    df = export_entity_dataframe("acheteur", ach, None, None, None)
    assert df.height > 0
    assert set(df["acheteur_id"].to_list()) == {ach}


def test_clear_sort_preserves_width_and_order():
    column_state = [
        {"colId": "objet", "width": 400, "sort": "asc", "sortIndex": 0},
        {"colId": "montant", "width": 150, "sort": "desc", "sortIndex": 1},
    ]
    cleared = clear_sort(column_state)
    assert all(c["sort"] is None and c["sortIndex"] is None for c in cleared)
    assert cleared[0]["width"] == 400 and cleared[0]["colId"] == "objet"
    assert cleared[1]["width"] == 150


def test_clear_sort_empty():
    assert clear_sort(None) == []


def test_entity_grid_column_defs_applies_persisted_layout():
    defs = entity_grid_column_defs(hidden_columns=[], column_state=[{"colId": "montant", "width": 999}])
    by_field = {d["field"]: d for d in defs}
    assert by_field["montant"]["width"] == 999


def test_build_entity_grid_has_pattern_matching_id_and_filter_only_persistence():
    grid = build_entity_grid("acheteur", "S123", "Toutes les années", [], None)
    assert isinstance(grid, dag.AgGrid)
    assert grid.id == {"type": "acheteur-grid", "entity_id": "S123", "year": "Toutes les années"}
    assert list(grid.persisted_props) == ["filterModel"]


def test_grid_type():
    assert grid_type("acheteur") == "acheteur-grid"
    assert grid_type("titulaire") == "titulaire-grid"
```

- [ ] **Step 2: Lancer les tests, vérifier l'échec**

Run: `uv run pytest tests/test_entity_grid.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'src.utils.entity_grid'`.

- [ ] **Step 3: Implémenter le module**

Créer `src/utils/entity_grid.py` :

```python
"""Logique de grille AG Grid scopée à une entité (acheteur/titulaire).

Factorise ce qui est commun aux pages acheteur.py et titulaire.py :
- scope SQL (WHERE acheteur_id/titulaire_id + année),
- datasource server-side (réutilise fetch_grid_page du Lot 1),
- export Excel filtré (réutilise export_dataframe),
- columnDefs + réapplication de la disposition persistée,
- fabrique de grille à id pattern-matching (persistance filterModel par fiche).

Les callbacks Dash sont enregistrés par register_entity_grid_callbacks()
(cf. module séparé de wiring) ; ici, seules des fonctions pures testables.
"""

import dash_ag_grid as dag
from dash import no_update

from src.figures import (
    AG_GRID_LOCALE_FR,  # noqa: F401  (réexport pratique)
    ag_grid,
)
from src.utils.grid import (
    apply_persisted_layout,
    export_dataframe,
    fetch_grid_page,
    grid_column_defs,
)

_TOUTES = "Toutes les années"


def grid_type(org_type: str) -> str:
    """Valeur de la clé `type` de l'id pattern-matching de la grille."""
    return f"{org_type}-grid"


def entity_scope(org_type: str, entity_id: str, year) -> tuple[str, list]:
    """WHERE SQL + params liés scopant les requêtes à cette entité (+ année).

    Reproduit _acheteur_scope/_titulaire_scope des pages, unifié par org_type.
    """
    if org_type == "titulaire":
        where_sql = "titulaire_id = ? AND titulaire_typeIdentifiant = 'SIRET'"
    else:
        where_sql = "acheteur_id = ?"
    params: list = [entity_id]
    if year and year != _TOUTES:
        where_sql += ' AND YEAR("dateNotification") = ?'
        params.append(int(year))
    return where_sql, params


def _sort_model_from_column_state(column_state) -> list:
    """Extrait le sortModel AG Grid d'un columnState (comme tableau.download_data)."""
    return [
        {"colId": c["colId"], "sort": c["sort"]}
        for c in (column_state or [])
        if c.get("sort")
    ]


def entity_grid_column_defs(hidden_columns, column_state) -> list[dict]:
    """columnDefs du schéma DECP + disposition (largeur/ordre) persistée."""
    defs = grid_column_defs(hidden_columns)
    return apply_persisted_layout(defs, column_state)


def fetch_entity_page(org_type, entity_id, year, request) -> tuple:
    """Datasource server-side scopé pour la grille entité.

    Renvoie ({"rowData": ..., "rowCount": total}, total, total_unique).
    """
    if request is None:
        return no_update, no_update, no_update
    base_where_sql, base_params = entity_scope(org_type, entity_id, year)
    rows, total, total_unique = fetch_grid_page(
        request.get("filterModel") or None,
        request.get("sortModel") or None,
        request.get("startRow", 0),
        request.get("endRow", 100),
        base_where_sql=base_where_sql,
        base_params=tuple(base_params),
    )
    return {"rowData": rows, "rowCount": total}, total, total_unique


def export_entity_dataframe(org_type, entity_id, year, filter_model, column_state):
    """DataFrame filtré/trié (état courant de la grille) pour l'export Excel."""
    base_where_sql, base_params = entity_scope(org_type, entity_id, year)
    sort_model = _sort_model_from_column_state(column_state)
    hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")]
    return export_dataframe(
        filter_model,
        sort_model,
        hidden_columns,
        base_where_sql=base_where_sql,
        base_params=tuple(base_params),
    )


def clear_sort(column_state) -> list[dict]:
    """columnState avec le tri effacé (sort/sortIndex à None), largeur/ordre/
    épinglage préservés. Approche #47-safe de tableau.reset_view."""
    return [{**col, "sort": None, "sortIndex": None} for col in (column_state or [])]


def build_entity_grid(org_type, entity_id, year, hidden_columns, column_state):
    """Grille AG Grid à id pattern-matching, scopée à (entité, année).

    L'id inclut entity_id + year → une entrée localStorage de filterModel par
    fiche/année (persistance native scopée). columnState n'est PAS persisté
    nativement (géré par un store global partagé) : persisted_props=["filterModel"].
    """
    grid_id = {"type": grid_type(org_type), "entity_id": entity_id, "year": year or _TOUTES}
    defs = entity_grid_column_defs(hidden_columns, column_state)
    return ag_grid(grid_id, defs, persisted_props=["filterModel"])
```

- [ ] **Step 4: Lancer les tests, vérifier le succès**

Run: `uv run pytest tests/test_entity_grid.py -v`
Expected: PASS pour les 12 tests.

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/utils/entity_grid.py tests/test_entity_grid.py
git add src/utils/entity_grid.py tests/test_entity_grid.py
git commit -m "feat(entity_grid): fonctions pures de grille scopée acheteur/titulaire (#41)"
```

---

## Task 5: `register_entity_grid_callbacks()` — wiring des callbacks partagés

**Files:**

- Modify: `src/utils/entity_grid.py` (ajouter la fonction de registration)
- Test: `tests/test_entity_grid.py` (test de fumée : la registration ne lève pas)

**Interfaces:**

- Consumes: fonctions pures de Task 4.
- Produces: `register_entity_grid_callbacks(org_type: str) -> None` — enregistre, pour la page `org_type`, les callbacks : datasource (grille MATCH → stores totaux fixes), rebuild de grille (nav/année/colonnes masquées), persistance columnState (grille → store partagé), reset (bouton → grille ALL), export filtré (bouton → Download), meta (totaux → nb_rows + état bouton export filtré).
- **Ids conventionnels attendus par page** (créés en Task 6/7) : `f"{org_type}_url"` (dcc.Location), `f"{org_type}_year"` (dropdown), `f"{org_type}-grid-container"` (html.Div hôte), `f"{org_type}-total"`, `f"{org_type}-total-unique"` (dcc.Store), `f"{org_type}-hidden-columns"` (dcc.Store), `"entity-grid-columns-state"` (dcc.Store partagé), `f"{org_type}_nb_rows"` (Span), `f"btn-{org_type}-reset"`, `f"btn-download-filtered-data-{org_type}"`, `f"{org_type}-download-filtered-data"` (dcc.Download).

- [ ] **Step 1: Écrire le test de fumée qui échoue**

Ajouter dans `tests/test_entity_grid.py` :

```python
def test_register_entity_grid_callbacks_smoke():
    """La registration ne lève pas (les ids/patterns sont valides pour Dash)."""
    import src.app  # noqa: F401
    from src.utils.entity_grid import register_entity_grid_callbacks

    # Ne doit pas lever ; idempotence non requise (appelée une fois par page).
    register_entity_grid_callbacks("acheteur")
```

- [ ] **Step 2: Lancer le test, vérifier l'échec**

Run: `uv run pytest tests/test_entity_grid.py::test_register_entity_grid_callbacks_smoke -v`
Expected: FAIL — `ImportError: cannot import name 'register_entity_grid_callbacks'`.

- [ ] **Step 3: Implémenter la registration**

Ajouter en tête de `src/utils/entity_grid.py` les imports Dash nécessaires :

```python
from dash import ALL, MATCH, Input, Output, State, callback, ctx, dcc
```

Puis ajouter à la fin du module :

```python
def register_entity_grid_callbacks(org_type: str) -> None:
    """Enregistre les callbacks de grille pour la page org_type (acheteur/titulaire).

    Appelée une fois par page. Utilise des closures sur org_type ; toute la
    logique délègue aux fonctions pures ci-dessus (testées en Task 4).
    """
    gtype = grid_type(org_type)

    # 1) Datasource server-side. Input MATCH (grille) → Output MATCH
    #    (getRowsResponse) + Outputs fixes (stores totaux). Autorisé en Dash 4.4.
    @callback(
        Output({"type": gtype, "entity_id": MATCH, "year": MATCH}, "getRowsResponse"),
        Output(f"{org_type}-total", "data"),
        Output(f"{org_type}-total-unique", "data"),
        Input({"type": gtype, "entity_id": MATCH, "year": MATCH}, "getRowsRequest"),
        prevent_initial_call=True,
    )
    def _get_rows(request):
        gid = ctx.triggered_id  # {"type", "entity_id", "year"}
        if request is None or gid is None:
            return no_update, no_update, no_update
        if request.get("filterModel") and request.get("startRow", 0) == 0:
            import json

            from src.utils.tracking import track_search

            track_search(json.dumps(request["filterModel"]), org_type)
        return fetch_entity_page(org_type, gid["entity_id"], gid["year"], request)

    # 2) (Re)construit la grille au changement de fiche (URL), d'année, ou de
    #    colonnes masquées. Le remontage réinitialise le filterModel (accepté
    #    au changement de fiche/année) ; pour les colonnes masquées voir §3bis.
    @callback(
        Output(f"{org_type}-grid-container", "children"),
        Input(f"{org_type}_url", "pathname"),
        Input(f"{org_type}_year", "value"),
        State(f"{org_type}-hidden-columns", "data"),
        State("entity-grid-columns-state", "data"),
    )
    def _build_grid(pathname, year, hidden_columns, column_state):
        from src.utils.table import get_default_hidden_columns

        entity_id = (pathname or "").split("/")[-1]
        if hidden_columns is None:
            hidden_columns = get_default_hidden_columns(org_type)
        return build_entity_grid(org_type, entity_id, year, hidden_columns, column_state)

    # 3) Persiste columnState (largeur/ordre/tri/visibilité) dans le store global
    #    partagé. Input MATCH (grille) → Output fixe (store). Autorisé Dash 4.4.
    #    allow_duplicate=True OBLIGATOIRE : ce store est partagé et
    #    register_entity_grid_callbacks est appelé pour acheteur ET titulaire,
    #    donc deux callbacks visent ce même Output fixe (sans allow_duplicate →
    #    collision, 500 au runtime dès que les deux pages sont câblées).
    @callback(
        Output("entity-grid-columns-state", "data", allow_duplicate=True),
        Input({"type": gtype, "entity_id": MATCH, "year": MATCH}, "columnState"),
        prevent_initial_call=True,
    )
    def _persist_column_state(column_state):
        return column_state or no_update

    # 3bis) Colonnes masquées → columnDefs in-place (préserve le filtre courant,
    #       contrairement au remontage). Input fixe → Output ALL (grille unique).
    @callback(
        Output({"type": gtype, "entity_id": ALL, "year": ALL}, "columnDefs"),
        Input(f"{org_type}-hidden-columns", "data"),
        State({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
        prevent_initial_call=True,
    )
    def _apply_hidden_columns(hidden_columns, column_states):
        from src.utils.table import get_default_hidden_columns

        if hidden_columns is None:
            hidden_columns = get_default_hidden_columns(org_type)
        # ALL → listes (une entrée par grille présente, ici 0 ou 1).
        return [
            entity_grid_column_defs(hidden_columns, cs) for cs in (column_states or [])
        ]

    # 4) Reset : efface filtres ET tris (columnState avec sort=None). Bouton fixe
    #    → grille ALL.
    @callback(
        Output({"type": gtype, "entity_id": ALL, "year": ALL}, "filterModel"),
        Output({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
        Input(f"btn-{org_type}-reset", "n_clicks"),
        State({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
        prevent_initial_call=True,
    )
    def _reset(_n, column_states):
        cleared = [clear_sort(cs) for cs in (column_states or [])]
        return [{} for _ in cleared], cleared

    # 5) Export filtré (état courant de la grille). Bouton fixe → Download fixe,
    #    lit filterModel/columnState de la grille via ALL (state).
    @callback(
        Output(f"{org_type}-download-filtered-data", "data"),
        Input(f"btn-download-filtered-data-{org_type}", "n_clicks"),
        State(f"{org_type}_url", "pathname"),
        State(f"{org_type}_year", "value"),
        State(f"{org_type}_nom", "children"),
        State({"type": gtype, "entity_id": ALL, "year": ALL}, "filterModel"),
        State({"type": gtype, "entity_id": ALL, "year": ALL}, "columnState"),
        prevent_initial_call=True,
    )
    def _download_filtered(_n, pathname, year, nom, filter_models, column_states):
        import datetime as _dt

        from src.utils.table import write_styled_excel

        entity_id = (pathname or "").split("/")[-1]
        filter_model = (filter_models or [None])[0]
        column_state = (column_states or [None])[0]
        if filter_model:
            import json

            from src.utils.tracking import track_search

            track_search(json.dumps(filter_model), f"{org_type} download")
        df = export_entity_dataframe(org_type, entity_id, year, filter_model, column_state)

        def to_bytes(buffer):
            write_styled_excel(df, buffer)

        date = _dt.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
        label = nom if isinstance(nom, str) else org_type
        return dcc.send_bytes(to_bytes, filename=f"decp_filtrées_{label}_{date}.xlsx")

    # 6) Meta : "X marchés (Y lignes)" + état du bouton d'export filtré (seuil 65k).
    @callback(
        Output(f"{org_type}_nb_rows", "children"),
        Output(f"btn-download-filtered-data-{org_type}", "disabled"),
        Output(f"btn-download-filtered-data-{org_type}", "children"),
        Output(f"btn-download-filtered-data-{org_type}", "title"),
        Input(f"{org_type}-total", "data"),
        Input(f"{org_type}-total-unique", "data"),
    )
    def _meta(total, total_unique):
        from src.utils.frontend import get_button_properties
        from src.utils.table import format_number

        total = total or 0
        total_unique = total_unique or 0
        nb_rows = (
            f"{format_number(total_unique) or 0} marchés "
            f"({format_number(total) or 0} lignes)"
        )
        disabled, children, title = get_button_properties(total)
        return nb_rows, disabled, children, title
```

> **Note :** `format_number` est dans `src/utils/table.py` (importé en local pour éviter les cycles). Vérifier lors de l'implémentation que `get_button_properties(count)` renvoie bien `(disabled, children, title)` (cf. `acheteur.update_download_button_acheteur`). Si sa signature/retour diffère, adapter l'appel dans `_meta` en conséquence.

- [ ] **Step 4: Lancer le test de fumée, vérifier le succès**

Run: `uv run pytest tests/test_entity_grid.py::test_register_entity_grid_callbacks_smoke -v`
Expected: PASS (aucune exception de validation Dash sur les patterns d'ids).

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/utils/entity_grid.py tests/test_entity_grid.py
git add src/utils/entity_grid.py tests/test_entity_grid.py
git commit -m "feat(entity_grid): register_entity_grid_callbacks (datasource/reset/export/meta) (#41)"
```

---

## Task 6: Migrer `acheteur.py` vers AG Grid

**Files:**

- Modify: `src/pages/acheteur.py`
- Test: manuel (Selenium couvert en Task 8) ; vérifier l'import de la page.

**Interfaces:**

- Consumes: `entity_grid.register_entity_grid_callbacks`, `entity_grid.build_entity_grid` (indirect via le container), `figures.get_top_org_ag_grid`, helpers existants.

- [ ] **Step 1: Remplacer le composant table et les stores dans le layout**

Dans `src/pages/acheteur.py` :

1. **Imports** : retirer `DataTable` de l'import `src.figures` ; ajouter `get_top_org_ag_grid`. Retirer les imports devenus inutiles (`filter_table_data`, `sort_table_data`, `prepare_table_data`, `COLUMNS`, `get_default_hidden_columns` si plus utilisés localement — garder ceux encore référencés). Ajouter :

```python
from src.utils.entity_grid import register_entity_grid_callbacks
```

2. **Supprimer** le bloc `DATATABLE = html.Div(className="marches_table", children=DataTable(dtid="acheteur_datatable", ...))` (lignes ~71-85).

3. **Convertir `layout` en fonction** `def layout(acheteur_id=None, **kwargs):` qui retourne l'actuelle liste, avec ces changements dans le corps :
   - Conserver `dcc.Location(id="acheteur_url", ...)`, tous les composants d'infos/carte/stats/histogramme **inchangés**.
   - Ajouter, à côté de `dcc.Store(id="acheteur-hidden-columns", storage_type="local")` :

```python
    dcc.Store(id="acheteur-total"),
    dcc.Store(id="acheteur-total-unique"),
    dcc.Store(id="entity-grid-columns-state", storage_type="local"),
```

- Remplacer le `DATATABLE` (dernier enfant, ligne ~256) par le container hôte de la grille :

```python
    html.Div(id="acheteur-grid-container", className="marches_table"),
```

> `entity-grid-columns-state` est **partagé** avec titulaire.py. Il est déclaré dans les deux `layout()` : Dash tolère un même id de Store présent sur des pages différentes (une seule page montée à la fois). Ne PAS le déclarer dans un layout global.

- [ ] **Step 2: Supprimer les callbacks DataTable, enregistrer les callbacks de grille**

Dans `src/pages/acheteur.py`, **supprimer** les callbacks devenus obsolètes :

- `get_last_marches_data` (Output `acheteur_datatable.*`).
- `download_filtered_acheteur_data` (remplacé par le callback export de `register_entity_grid_callbacks`).
- le `clientside_callback` `clean_filters` + le `dcc.Store(id="filter-cleanup-trigger-acheteur")`.
- `store_hidden_columns` (Output `acheteur_datatable.hidden_columns`).
- `reset_view` (Output `acheteur_datatable.filter_query/sort_by`) — remplacé par le reset de la grille.

**Conserver inchangés** : `update_acheteur_infos`, `update_acheteur_map`, `update_acheteur_stats`, `update_download_button_acheteur`, `download_acheteur_data` (bouton « toutes les données »), `update_hidden_columns_from_checkboxes`, `toggle_acheteur_columns`, `update_acheteur_distance_histogram`.

**Corriger** `update_checkboxes_from_hidden_columns` : son `Input` pointe aujourd'hui vers `acheteur_datatable.hidden_columns` (prop qui disparaît avec la DataTable). Le rebrancher sur le **store** :

```python
@callback(
    Output("acheteur_column_list", "selected_rows"),
    Input("acheteur-hidden-columns", "data"),
    State("acheteur_column_list", "selected_rows"),  # pour éviter la boucle infinie
)
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
    hidden_cols = hidden_cols or get_default_hidden_columns("acheteur")
    visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
    return visible_cols
```

(Garder donc les imports `COLUMNS` et `get_default_hidden_columns` dans `acheteur.py`.)

**Migrer le top 10** : dans `get_top_titulaires`, remplacer `get_top_org_table(...)` par `get_top_org_ag_grid(...)` :

```python
@callback(
    Output(component_id="top10_titulaires", component_property="children"),
    Input(component_id="acheteur_url", component_property="pathname"),
    Input(component_id="acheteur_year", component_property="value"),
)
def get_top_titulaires(pathname, ach_year):
    where_sql, params = _acheteur_scope(pathname, ach_year)
    table = get_top_org_ag_grid(
        query_marches(where_sql, params).lazy(), "titulaire", ["titulaire_distance"]
    )
    return make_card(fig=table, title="Top titulaires", lg=12, xl=12)
```

**Enregistrer les callbacks de grille** (une fois, au niveau module, après les définitions de callbacks) :

```python
register_entity_grid_callbacks("acheteur")
```

**Unifier le scope** : `_acheteur_scope` reste utilisé par les callbacks conservés (carte, stats, download « toutes les données », top 10) — le **garder** mais le faire **déléguer** à `entity_scope` (source unique, plus de duplication du WHERE) :

```python
from src.utils.entity_grid import entity_scope, register_entity_grid_callbacks


def _acheteur_scope(pathname: str, ach_year: str | None) -> tuple[str, list]:
    """WHERE SQL scopant les requêtes à cet acheteur (et éventuellement une année)."""
    return entity_scope("acheteur", pathname.split("/")[-1], ach_year)
```

- [ ] **Step 3: Corriger le smoke test entity_grid (double enregistrement)**

À partir de maintenant, `acheteur.py` appelle `register_entity_grid_callbacks("acheteur")` **au niveau module** (à l'import). Or `tests/test_entity_grid.py::test_register_entity_grid_callbacks_smoke` fait `import src.app` (qui importe la page → 1er enregistrement) **puis** rappelle `register_entity_grid_callbacks("acheteur")` (2e enregistrement) → collision des Outputs org-spécifiques (`acheteur-total`, etc. — sans `allow_duplicate`), erreur `DuplicateCallback`.

Rendre ce test « forward-safe » : ne plus ré-enregistrer, mais **vérifier que l'enregistrement fait à l'import a bien eu lieu**. Remplacer son corps par :

```python
def test_register_entity_grid_callbacks_registered_via_page_import():
    """Les callbacks de grille entité sont enregistrés (une fois) à l'import
    des pages acheteur/titulaire — pas de double enregistrement."""
    import src.app  # noqa: F401  # importe les pages → register_entity_grid_callbacks

    from dash import get_app

    callback_map = get_app().callback_map
    # Le store columnState partagé est piloté par un callback enregistré.
    assert any("entity-grid-columns-state.data" in k for k in callback_map)
```

(Renommer le test depuis `test_register_entity_grid_callbacks_smoke`.)

- [ ] **Step 4: Vérifier que la page s'importe sans erreur**

Run: `uv run python -c "import src.app; import src.pages.acheteur; print('acheteur OK')"`
Expected: `acheteur OK` (aucune exception de validation Dash : ids en double, patterns invalides, Output dupliqués sans `allow_duplicate`).

> Si Dash signale un Output dupliqué autre que ceux déjà gérés, ajouter `allow_duplicate=True` + `prevent_initial_call=True` sur l'Output concerné (le store `entity-grid-columns-state.data` a déjà `allow_duplicate=True` depuis la Task 5).

- [ ] **Step 5: Vérifier le rendu réel de la page (skill run)**

Lancer l'app et charger `/acheteurs/<un_id_présent_dans_test.parquet>`. Vérifier : la grille s'affiche, filtre multi-conditions (ET/OU + Contient) fonctionne, tri fonctionne, le compteur affiche « X marchés (Y lignes) », le bouton Réinitialiser vide filtres+tris, l'export filtré télécharge un fichier cohérent, le sélecteur de colonnes masque/affiche, le top 10 s'affiche en AG Grid.

> Utiliser le skill `run` (ou `uv run run.py`) ; ne pas cocher cette étape tant que la grille n'est pas vérifiée visuellement fonctionnelle.

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/pages/acheteur.py
git add src/pages/acheteur.py
git commit -m "feat(acheteur): migration du tableau des marchés vers AG Grid (#41)"
```

---

## Task 7: Migrer `titulaire.py` vers AG Grid

**Files:**

- Modify: `src/pages/titulaire.py`

**Interfaces:** identiques à Task 6, substitutions `acheteur`→`titulaire`. Différences spécifiques titulaire détaillées ci-dessous.

- [ ] **Step 1: Layout — stores + container**

Dans `src/pages/titulaire.py` :

- Imports : retirer `DataTable`, ajouter `get_top_org_ag_grid` et `from src.utils.entity_grid import register_entity_grid_callbacks`.
- Supprimer le bloc `DATATABLE = html.Div(..., DataTable(dtid="titulaire_datatable", ...))` (lignes ~70-84).
- `layout` → `def layout(titulaire_id=None, **kwargs):`.
- Ajouter près de `dcc.Store(id="titulaire-hidden-columns", ...)` :

```python
    dcc.Store(id="titulaire-total"),
    dcc.Store(id="titulaire-total-unique"),
    dcc.Store(id="entity-grid-columns-state", storage_type="local"),
```

- Remplacer le `DATATABLE` final (ligne ~271) par :

```python
    html.Div(id="titulaire-grid-container", className="marches_table"),
```

- [ ] **Step 2: Callbacks — suppression + registration + top 10**

- Supprimer : `get_last_marches_data` (Output `titulaire_datatable.*`), `download_filtered_titulaire_data`, le `clientside_callback` `clean_filters` + `dcc.Store(id="filter-cleanup-trigger-titulaire")`, `store_hidden_columns` (Output `titulaire_datatable.hidden_columns`), `reset_view` (Output `titulaire_datatable.*`).
- Conserver inchangés : `update_titulaire_infos`, `update_titulaire_map`, `update_titulaire_stats`, `update_download_button_titulaire`, `download_titulaire_data`, `update_hidden_columns_from_checkboxes`, `toggle_titulaire_columns`, `update_titulaire_distance_histogram`.
- **Corriger** `update_checkboxes_from_hidden_columns` (comme pour acheteur) : rebrancher son `Input("titulaire_datatable", "hidden_columns")` sur `Input("titulaire-hidden-columns", "data")` (le store), corps inchangé sinon (utilise `COLUMNS`/`get_default_hidden_columns("titulaire")`). Garder ces imports.
- **Top 10 (spécifique titulaire)** : `get_top_acheteurs` n'utilise PAS `make_card` — il retourne la table directement dans `html.Div(id="top10_acheteurs")` (qui a déjà son `<H3>Top acheteurs` dans le layout, lignes ~189-194). Migrer ainsi :

```python
@callback(
    Output(component_id="top10_acheteurs", component_property="children"),
    Input(component_id="titulaire_url", component_property="pathname"),
    Input(component_id="titulaire_year", component_property="value"),
)
def get_top_acheteurs(pathname, titulaire_year):
    where_sql, params = _titulaire_scope(pathname, titulaire_year)
    return get_top_org_ag_grid(
        query_marches(where_sql, params).lazy(), "acheteur", ["titulaire_distance"]
    )
```

- Enregistrer les callbacks de grille (niveau module) :

```python
register_entity_grid_callbacks("titulaire")
```

- **Unifier le scope** : garder `_titulaire_scope` (utilisé par carte/stats/download/top 10) mais le faire déléguer à `entity_scope` — qui ajoute déjà `titulaire_typeIdentifiant = 'SIRET'` pour `org_type="titulaire"` :

```python
from src.utils.entity_grid import entity_scope, register_entity_grid_callbacks


def _titulaire_scope(pathname: str, titulaire_year: str | None) -> tuple[str, list]:
    """WHERE SQL scopant les requêtes à ce titulaire (et éventuellement une année)."""
    return entity_scope("titulaire", pathname.split("/")[-1], titulaire_year)
```

- [ ] **Step 3: Vérifier l'import de la page**

Run: `uv run python -c "import src.app; import src.pages.titulaire; print('titulaire OK')"`
Expected: `titulaire OK`.

- [ ] **Step 4: Vérifier le rendu réel (skill run)**

Charger `/titulaires/<un_id_SIRET_présent_dans_test.parquet>`. Mêmes vérifications qu'en Task 6 Step 4, plus : le scope SIRET s'applique (pas de lignes non-SIRET). Vérifier aussi qu'après avoir redimensionné/réordonné une colonne sur `/acheteurs/...` puis navigué vers `/titulaires/...`, la disposition des colonnes est **conservée** (store partagé), mais que le **filtre** n'est PAS repris d'une fiche à l'autre.

- [ ] **Step 5: Commit**

```bash
pre-commit run --files src/pages/titulaire.py
git add src/pages/titulaire.py
git commit -m "feat(titulaire): migration du tableau des marchés vers AG Grid (#41)"
```

---

## Task 8: Mettre à jour les tests d'intégration + suite complète

**Files:**

- Modify: `tests/test_main.py:63-95` (`test_002_filter_persistence`), `tests/test_main.py:98-121` (`test_003_tableau_download`)

> Note : la correction « Dash 3.4 → Dash 4.4 » dans `CLAUDE.md` a déjà été faite par l'utilisateur — ne pas la refaire.

- [ ] **Step 1: Adapter `test_002_filter_persistence` aux sélecteurs AG Grid**

Ce test pilote `/acheteurs/123` et `/titulaires/345` et cherche l'input de filtre de colonne DataTable (`.marches_table th[data-dash-column="dateNotification"] input[type="text"]`). Avec AG Grid, le filtre flottant d'une colonne est un `input` dans `.ag-floating-filter-input` de la colonne correspondante. Lire d'abord le test actuel :

Run: `uv run pytest tests/test_main.py::test_002_filter_persistence -v`
Expected (avant correction) : FAIL/erreur (sélecteur DataTable absent sur la grille AG Grid).

Adapter le corps pour cibler le filtre flottant AG Grid. Remplacer la recherche du champ de filtre par (adapter aux noms de variables du test existant) :

```python
    # AG Grid : le filtre flottant de la colonne dateNotification est un input
    # dans la cellule d'en-tête flottante correspondante. On l'atteint via le
    # col-id AG Grid (= nom du champ).
    filter_input = dash_duo.wait_for_element(
        'div[col-id="dateNotification"] .ag-floating-filter-input input',
        timeout=10,
    )
    filter_input.send_keys("2024")
```

> Le `col-id` AG Grid vaut le `field` de la columnDef (= nom de colonne DECP). Vérifier le sélecteur exact au moment de l'implémentation en inspectant le DOM rendu (le skill `run` peut servir à confirmer la structure `.ag-floating-filter-input`). Conserver l'intention du test (persistance du filtre après rechargement) : après saisie, recharger la page et vérifier que la valeur du filtre persiste — mais **attention** : la persistance filterModel est désormais scopée par `(entity_id, year)` ; le test doit recharger la **même** fiche pour observer la persistance.

- [ ] **Step 2: Vérifier `test_003_tableau_download`**

Ce test appelle directement `download_data(1, None, None)` (tableau), `download_acheteur_data(1, "/acheteurs/123", "2025", "ACHETEUR 1")`, `download_titulaire_data(1, "/titulaires/345", "2025", "TITULAIRE 1")`. Ces trois callbacks (« toutes les données ») ne changent PAS de signature dans ce lot. Vérifier qu'ils s'importent et s'appellent toujours :

Run: `uv run pytest tests/test_main.py::test_003_tableau_download -v`
Expected: PASS sans modification. Si un `ImportError` survient (ex. un helper supprimé était importé en tête du test), corriger l'import ; ne pas changer la logique du test.

- [ ] **Step 3: Lancer les tests ciblés**

Run: `uv run pytest tests/test_main.py::test_002_filter_persistence tests/test_main.py::test_003_tableau_download tests/test_entity_grid.py tests/test_grid.py tests/test_figures.py -v`
Expected: PASS.

- [ ] **Step 4: Lancer la suite complète (fin de lot)**

Run: `uv run pytest`
Expected: PASS (hors flakes Selenium préexistants éventuels — les identifier en relançant le test isolé et en confirmant qu'il échoue aussi sur `dev` sans ce lot ; cf. mémoire projet). Aucun échec **nouveau** attribuable à ce lot.

- [ ] **Step 5: Commit**

```bash
pre-commit run --files tests/test_main.py
git add tests/test_main.py
git commit -m "test(acheteur/titulaire): sélecteurs AG Grid pour la grille des marchés (#41)"
```

---

## Notes de fin de lot

- **`entity-grid-columns-state`** est un store `storage_type="local"` partagé acheteur+titulaire ; sa déclaration apparaît dans les deux `layout()`. Si Dash se plaint d'un id dupliqué au chargement simultané (ne devrait pas : une page montée à la fois), déplacer sa déclaration dans un layout applicatif global unique (`src/app.py`) et retirer des pages.
- **Persistance filterModel scopée** : repose sur le fait que dash-ag-grid sérialise l'id dict comme clé localStorage. Le vérifier en Task 6 Step 4 (deux acheteurs distincts → filtres indépendants). Si la persistance ne discrimine pas par id, repli documenté dans la spec (« reset systématique au changement de fiche » — le remontage de grille le fait déjà, seul le rappel du filtre par fiche serait perdu).
- **Scope unifié** : `entity_scope` (entity_grid.py) est la source unique du WHERE ; `_acheteur_scope`/`_titulaire_scope` sont de fins wrappers qui y délèguent. `_top_org_aggregate` (figures.py) est la source unique de l'agrégation top N, partagée par `get_top_org_table` et `get_top_org_ag_grid`.
- **Hors périmètre confirmé** : sélecteur de colonnes (`make_column_picker` reste DataTable, partagé avec tableau.py), `observatoire.py` (Lot 2b).
