From 8a63f72bee77398ae0f7a5c7d1e639a69ab7db0e Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sun, 28 Sep 2025 18:40:00 +0200 Subject: [PATCH] Vue fournisseur minimaliste #35 --- pyproject.toml | 3 +- src/pages/acheteur.py | 2 +- src/pages/fournisseur.py | 266 ++++++++++++++++++++++++++++++++++++++- src/utils.py | 4 +- 4 files changed, 268 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 74be8fa..e533afa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ dependencies = [ "python-dotenv", "xlsxwriter", "plotly[express]", - "httpx" + "httpx", + "pandas" # utilisé pour la création de certains graphiques ] [project.optional-dependencies] diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index e3fa979..a70ff04 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -8,7 +8,7 @@ from src.utils import format_number, get_annuaire_data, get_departement_region, register_page( __name__, - path_template="/acheteur/", + path_template="/acheteurs/", title="decp.info - acheteur", name="Acheteur", order=5, diff --git a/src/pages/fournisseur.py b/src/pages/fournisseur.py index 509ca1f..077d276 100644 --- a/src/pages/fournisseur.py +++ b/src/pages/fournisseur.py @@ -1,13 +1,273 @@ -from dash import html, register_page +import datetime + +import polars as pl +from dash import Input, Output, State, callback, dash_table, dcc, html, register_page + +from src.figures import point_on_map +from src.utils import format_number, get_annuaire_data, get_departement_region, lf register_page( __name__, - path_template="/fournisseur/", + path_template="/fournisseurs/", title="decp.info - fournisseur", name="Fournisseur", order=5, ) +# 21690123100011 + layout = [ - html.Div(className="container", children=["Cette page est encore en construction."]) + dcc.Store(id="fournisseur_data", storage_type="memory"), + dcc.Location(id="url", refresh="callback-nav"), + html.Div( + className="container", + children=[ + html.Div( + className="wrapper", + children=[ + html.H2( + className="org_title", + children=[ + html.Span(id="fournisseur_siret"), + " - ", + html.Span(id="fournisseur_nom"), + ], + ), + html.Div( + className="org_year", + children=dcc.Dropdown( + id="fournisseur_year", + options=["Toutes"] + + [ + str(year) + for year in range( + 2018, int(datetime.date.today().year) + 1 + ) + ], + placeholder="Année", + ), + ), + html.Div( + className="org_infos", + children=[ + # TODO: ajouter le type d'acheteur : commune, CD, CR, etc. + html.P( + ["Commune : ", html.Strong(id="fournisseur_commune")] + ), + html.P( + [ + "Département : ", + html.Strong(id="fournisseur_departement"), + ] + ), + html.P(["Région : ", html.Strong(id="fournisseur_region")]), + html.A( + id="fournisseur_lien_annuaire", + children="Plus de détails sur l'Annuaire des entreprises", + target="_blank", + ), + ], + ), + html.Div( + className="org_stats", + children=[ + html.P(id="fournisseur_titre_stats"), + html.P(id="fournisseur_marches_remportes"), + html.P(id="fournisseur_acheteurs_differents"), + html.Button( + "Téléchargement au format Excel", + id="btn-download-fournisseur-data", + ), + dcc.Download(id="download-fournisseur-data"), + ], + ), + html.Div(className="org_map", id="fournisseur_map"), + ], + ), + # récupérer les données de l'acheteur sur l'api annuaire + html.H3("Derniers marchés publics remportés"), + html.Div(id="fournisseur_last_marches", children=""), + ], + ), ] + + +@callback( + Output(component_id="fournisseur_siret", component_property="children"), + Output(component_id="fournisseur_nom", component_property="children"), + Output(component_id="fournisseur_commune", component_property="children"), + Output(component_id="fournisseur_map", component_property="children"), + Output(component_id="fournisseur_departement", component_property="children"), + Output(component_id="fournisseur_region", component_property="children"), + Output(component_id="fournisseur_lien_annuaire", component_property="href"), + Input(component_id="url", component_property="pathname"), +) +def update_fournisseur_infos(url): + fournisseur_siret = url.split("/")[-1] + if len(fournisseur_siret) != 14: + fournisseur_siret = ( + f"Le SIRET renseigné doit faire 14 caractères ({fournisseur_siret})" + ) + data = get_annuaire_data(fournisseur_siret) + data_etablissement = data["matching_etablissements"][0] + fournisseur_map = point_on_map( + data_etablissement["latitude"], data_etablissement["longitude"] + ) + 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/{fournisseur_siret}" + ) + return ( + fournisseur_siret, + data["nom_raison_sociale"], + data_etablissement["libelle_commune"], + fournisseur_map, + departement, + nom_region, + lien_annuaire, + ) + + +@callback( + Output(component_id="fournisseur_marches_remportes", component_property="children"), + Output( + component_id="fournisseur_acheteurs_differents", component_property="children" + ), + Input(component_id="fournisseur_data", component_property="data"), +) +def update_fournisseur_stats(data): + df = pl.DataFrame(data) + if df.height == 0: + df = pl.DataFrame(schema=lf.collect_schema()) + df_marches = df.unique("uid") + nb_marches = format_number(df_marches.height) + # somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item())) + marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"] + # + ", pour un total de ", html.Strong(somme_marches + " €")] + del df_marches + + nb_acheteurs = df.unique("acheteur_id").height + nb_acheteurs = [ + html.Strong(format_number(nb_acheteurs)), + " fournisseurs (SIRET) différents", + ] + del df + + return marches_remportes, nb_acheteurs + + +@callback( + Output(component_id="fournisseur_data", component_property="data"), + Input(component_id="url", component_property="pathname"), + Input(component_id="fournisseur_year", component_property="value"), +) +def get_fournisseur_marches_data(url, fournisseur_year: str) -> pl.LazyFrame: + fournisseur_siret = url.split("/")[-1] + lff = lf.filter( + (pl.col("titulaire_id") == fournisseur_siret) + & (pl.col("titulaire_typeIdentifiant") == "SIRET") + ) + lff = lff.fill_null("") + lff = lff.select( + "uid", + "objet", + "dateNotification", + "acheteur_id", + "acheteur_nom", + "montant", + "codeCPV", + "dureeMois", + ) + if fournisseur_year and fournisseur_year != "Toutes": + lff = lff.filter( + pl.col("dateNotification").cast(pl.String).str.starts_with(fournisseur_year) + ) + lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True) + + data = lff.collect(engine="streaming").to_dicts() + return data + + +@callback( + Output(component_id="fournisseur_last_marches", component_property="children"), + Input(component_id="fournisseur_data", component_property="data"), +) +def get_last_marches_table(data) -> html.Div: + columns = [ + "uid", + "objet", + "dateNotification", + "acheteur_nom", + "montant", + "codeCPV", + "dureeMois", + ] + + table = html.Div( + className="marches_table", + id="marches_datatable", + children=dash_table.DataTable( + data=data, + page_action="native", + columns=[ + { + "name": i, + "id": i, + "presentation": "markdown", + "type": "text", + "format": {"nully": "N/A"}, + "hideable": False, + } + for i in columns + if i not in ["titulaire_id"] + ], + page_size=10, + style_cell_conditional=[ + { + "if": {"column_id": "objet"}, + "minWidth": "300px", + "textAlign": "left", + "overflow": "hidden", + "lineHeight": "14px", + "whiteSpace": "normal", + }, + { + "if": {"column_id": "titulaire_nom"}, + "minWidth": "200px", + "textAlign": "left", + "overflow": "hidden", + "lineHeight": "14px", + "whiteSpace": "normal", + }, + ], + ), + ) + return table + + +@callback( + Output("download-fournisseur-data", "data"), + Input("btn-download-fournisseur-data", "n_clicks"), + State(component_id="fournisseur_data", component_property="data"), + State(component_id="fournisseur_nom", component_property="children"), + State(component_id="fournisseur_year", component_property="value"), + prevent_initial_call=True, +) +def download_fournisseur_data( + n_clicks, + data: [dict], + fournisseur_nom: str, + annee: str, +): + df_to_download = pl.DataFrame(data) + + def to_bytes(buffer): + df_to_download.write_excel( + buffer, worksheet="DECP" if annee in ["Toutes", None] else annee + ) + + date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + return dcc.send_bytes(to_bytes, filename=f"decp_{fournisseur_nom}_{date}.xlsx") diff --git a/src/utils.py b/src/utils.py index 96c2bfb..452a618 100644 --- a/src/utils.py +++ b/src/utils.py @@ -56,7 +56,7 @@ def add_org_links(dff: pl.DataFrame): dff = dff.with_columns( pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET") .then( - '' + pl.col("titulaire_id") @@ -67,7 +67,7 @@ def add_org_links(dff: pl.DataFrame): ) dff = dff.with_columns( ( - '' + pl.col("acheteur_id")