From ee98aa8186d01a51dccf9cd6cf4ff654eca89f45 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:30:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20tools=20stats=5Facheteur=20/=20sta?= =?UTF-8?q?ts=5Ftitulaire=20(agr=C3=A9gations)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/queries.py | 93 ++++++++++++++++++++++++++++++++++++++- tests/mcp/test_queries.py | 35 ++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/mcp/queries.py b/src/mcp/queries.py index d52f8a1..2716363 100644 --- a/src/mcp/queries.py +++ b/src/mcp/queries.py @@ -2,7 +2,7 @@ import re from src.api.filters import FilterError, build_where -from src.db import count_marches, query_marches +from src.db import aggregate_marches, count_marches, query_marches from src.db import schema as duckdb_schema from src.mcp.serialization import to_json_records from src.utils.data import DF_ACHETEURS, DF_TITULAIRES @@ -129,3 +129,94 @@ def search_organisations( } for r in df.to_dicts() ] + + +def _org_identite(org_type: str, org_id: str) -> dict: + df = query_marches( + where_sql=f'"{org_type}_id" = ?', + params=[org_id], + columns=[ + f"{org_type}_id", + f"{org_type}_nom", + f"{org_type}_departement_nom", + f"{org_type}_commune_nom", + ], + limit=1, + ) + if df.height == 0: + return {"id": org_id, "nom": None, "departement": None, "commune": None} + r = df.row(0, named=True) + return { + "id": org_id, + "nom": r[f"{org_type}_nom"], + "departement": r[f"{org_type}_departement_nom"], + "commune": r[f"{org_type}_commune_nom"], + } + + +def compute_org_stats(org_type: str, org_id: str) -> dict: + """Statistiques agrégées d'un acheteur ou titulaire.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + other = "titulaire" if org_type == "acheteur" else "acheteur" + where_sql = f'"{org_type}_id" = ?' + params = [org_id] + identite = _org_identite(org_type, org_id) + + totals = aggregate_marches( + select_sql='COUNT("uid") AS nb, COALESCE(SUM("montant"), 0) AS montant_total', + where_sql=where_sql, + params=params, + ) + nb = int(totals["nb"][0]) + if nb == 0: + return { + "identite": identite, + "nb_marches": 0, + "montant_total": 0, + "repartition_annuelle": [], + f"top_{other}s": [], + "top_cpv": [], + } + + annuelle = aggregate_marches( + select_sql=( + "CAST(date_part('year', \"dateNotification\") AS INTEGER) AS annee, " + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by="date_part('year', \"dateNotification\")", + order_by="annee", + ) + top_other = aggregate_marches( + select_sql=( + f'"{other}_id" AS id, any_value("{other}_nom") AS nom, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by=f'"{other}_id"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + top_cpv = aggregate_marches( + select_sql='"codeCPV" AS cpv, COUNT("uid") AS nb_marches', + where_sql=where_sql, + params=params, + group_by='"codeCPV"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + return { + "identite": identite, + "nb_marches": nb, + "montant_total": float(totals["montant_total"][0]), + "repartition_annuelle": to_json_records(annuelle), + f"top_{other}s": to_json_records(top_other), + "top_cpv": to_json_records(top_cpv), + } diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index b8031da..83605b6 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -1,7 +1,12 @@ import pytest import src.utils.search as search_mod -from src.mcp.queries import build_where_args, search_marches, search_organisations +from src.mcp.queries import ( + build_where_args, + compute_org_stats, + search_marches, + search_organisations, +) from src.utils.data import DF_ACHETEURS from src.utils.search import search_org @@ -80,3 +85,31 @@ def test_search_marches_no_match_is_empty(): def test_search_marches_bad_filter_returns_error(): result = search_marches(filtres_avances={"colonne_bidon__exact": "x"}) assert "error" in result + + +def test_compute_org_stats_acheteur_known(): + stats = compute_org_stats("acheteur", "123") + assert stats["nb_marches"] >= 1 + assert stats["montant_total"] == 10 + assert stats["identite"]["id"] == "123" + assert stats["identite"]["nom"] == "ACHETEUR 1" + assert "top_titulaires" in stats + assert "top_cpv" in stats + # répartition annuelle dérivée de dateNotification (2025) + annees = [row["annee"] for row in stats["repartition_annuelle"]] + assert 2025 in annees + + +def test_compute_org_stats_titulaire_known(): + stats = compute_org_stats("titulaire", "345") + assert stats["nb_marches"] >= 1 + assert "top_acheteurs" in stats + + +def test_compute_org_stats_unknown_is_empty(): + stats = compute_org_stats("acheteur", "inconnu-xyz") + assert stats["nb_marches"] == 0 + assert stats["montant_total"] == 0 + assert stats["repartition_annuelle"] == [] + assert stats["top_titulaires"] == [] + assert stats["top_cpv"] == []