diff --git a/README.md b/README.md
index e9183ca..8537ef3 100644
--- a/README.md
+++ b/README.md
@@ -40,8 +40,9 @@ Ne pas oublier de mettre à jour les fichier .env.
#### 2.2.0 ()
+- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
-- Moins de colonnes affichées par défaut ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
+- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
##### 2.1.7 (11 novembre 2025)
diff --git a/pyproject.toml b/pyproject.toml
index 5f29774..c81d016 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,8 @@ dependencies = [
"xlsxwriter",
"plotly[express]",
"httpx",
- "pandas" # utilisé pour la création de certains graphiques
+ "pandas", # utilisé pour la création de certains graphiques
+ "unidecode"
]
[project.optional-dependencies]
diff --git a/src/assets/style.css b/src/assets/style.css
index e29f53d..d5b7cfb 100644
--- a/src/assets/style.css
+++ b/src/assets/style.css
@@ -148,6 +148,35 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c
margin-top: 25px;
}
+/* Page de recherche */
+
+#search {
+ margin: 50px auto 0px auto;
+ width: 450px;
+ font-size: 18px;
+ height: 30px;
+ display: block;
+}
+
+.search_options {
+ margin: 16px auto;
+ width: 450px;
+}
+
+.search_options input {
+ margin-right: 12px;
+}
+
+.results_acheteur {
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.results_titulaire {
+ grid-column: 2;
+ grid-row: 1;
+}
+
/* Menu de navigation */
.navbar {
@@ -177,7 +206,7 @@ summary > h3 {
padding-top: 28px;
}
-/* Vue acheteur/titulaire */
+/* Vue acheteur/titulaire/recherche */
.wrapper {
display: grid;
grid-gap: 10px;
@@ -185,9 +214,6 @@ summary > h3 {
justify-content: space-between;
}
-.wrapper > div {
-}
-
.org_title {
grid-column: 1 / 3;
grid-row: 1;
diff --git a/src/pages/recherche.py b/src/pages/recherche.py
new file mode 100644
index 0000000..e74a720
--- /dev/null
+++ b/src/pages/recherche.py
@@ -0,0 +1,104 @@
+from dash import Input, Output, callback, dash_table, dcc, html, register_page
+
+from src.utils import (
+ df_acheteurs,
+ df_titulaires,
+ meta_content,
+ search_org,
+ setup_table_columns,
+)
+
+name = "Recherche"
+
+register_page(
+ __name__,
+ path="/",
+ title=meta_content["title"],
+ name=name,
+ description=meta_content["description"],
+ image_url=meta_content["image_url"],
+ order=0,
+)
+
+layout = html.Div(
+ className="container",
+ children=[
+ dcc.Input(
+ id="search",
+ type="text",
+ placeholder="Nom d'acheteur, d'entreprise, SIREN...",
+ ),
+ # html.Div(
+ # className="search_options",
+ # children=[dcc.RadioItems(options=["Acheteur(s)"])],
+ # ),
+ html.Div(id="search_results", className="wrapper"),
+ ],
+)
+
+
+@callback(
+ Output("search_results", "children"),
+ Input("search", "value"),
+ prevent_initial_call=True,
+)
+def update_search_results(query):
+ if len(query) >= 1:
+ content = []
+
+ for org_type in ["acheteur", "titulaire"]:
+ if org_type == "acheteur":
+ dff = df_acheteurs
+ elif org_type == "titulaire":
+ dff = df_titulaires
+ else:
+ raise ValueError(f"{org_type} is not supported")
+
+ # Search acheteurs and titulaires using the same function
+ results = search_org(dff, query, org_type=org_type)
+ count = results.height
+
+ # Format output
+ columns, tooltip = setup_table_columns(results, hideable=False)
+
+ org_content = [
+ html.Div(
+ className=f"results_{org_type}",
+ children=[
+ html.H3(f"{org_type.title()}s : {count}"),
+ dash_table.DataTable(
+ columns=columns,
+ data=results.to_dicts(),
+ page_size=10,
+ # style_table={"overflowX": "auto"},
+ markdown_options={"html": True},
+ cell_selectable=False,
+ style_cell_conditional=[
+ {
+ "if": {"column_id": "acheteur_nom"},
+ "maxWidth": "250px",
+ "textAlign": "left",
+ "overflow": "hidden",
+ "lineHeight": "18px",
+ "whiteSpace": "normal",
+ },
+ {
+ "if": {"column_id": "titulaire_nom"},
+ "maxWidth": "250px",
+ "textAlign": "left",
+ "overflow": "hidden",
+ "lineHeight": "18px",
+ "whiteSpace": "normal",
+ },
+ ],
+ ),
+ ],
+ )
+ if count > 0
+ else html.P(f"Aucun {org_type} trouvé."),
+ ]
+ content.extend(org_content)
+
+ return content
+ else:
+ return html.P("")
diff --git a/src/pages/tableau.py b/src/pages/tableau.py
index 4b3d380..83bfffe 100644
--- a/src/pages/tableau.py
+++ b/src/pages/tableau.py
@@ -25,7 +25,7 @@ schema = df.collect_schema()
name = "Tableau"
register_page(
__name__,
- path="/",
+ path="/tableau",
title=meta_content["title"],
name=name,
description=meta_content["description"],
diff --git a/src/utils.py b/src/utils.py
index 23ad4fe..9552df2 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -8,6 +8,7 @@ import polars.selectors as cs
from httpx import get
from polars import Schema
from polars.exceptions import ComputeError
+from unidecode import unidecode
operators = [
["s<", "<"],
@@ -50,30 +51,47 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
return dff
-def add_links(dff: pl.DataFrame):
- dff = dff.with_columns(
- pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
- .then(
- ''
- + pl.col("titulaire_id")
- + ""
- )
- .otherwise(pl.col("titulaire_id"))
- .alias("titulaire_id")
- )
-
- for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
- dff = dff.with_columns(
- (
- f''
- + pl.col(column)
- + ""
- ).alias(column)
- )
+def add_links(dff: pl.DataFrame, target: str = "_blank"):
+ for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
+ if col in dff.columns:
+ if col.startswith("titulaire_"):
+ dff = dff.with_columns(
+ pl.when(
+ pl.Expr.or_(
+ pl.col("titulaire_typeIdentifiant").is_null(),
+ pl.col("titulaire_typeIdentifiant") == "SIRET",
+ )
+ )
+ .then(
+ ''
+ + pl.col(col)
+ + ""
+ )
+ .otherwise(pl.col(col))
+ .alias(col)
+ )
+ if col.startswith("acheteur_"):
+ dff = dff.with_columns(
+ (
+ ''
+ + pl.col(col)
+ + ""
+ ).alias(col)
+ )
+ if col == "uid":
+ dff = dff.with_columns(
+ (
+ ''
+ + pl.col("uid")
+ + ""
+ ).alias("uid")
+ )
return dff
@@ -208,6 +226,18 @@ def get_decp_data() -> pl.DataFrame:
return lff.collect()
+def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
+ lff = dff.lazy()
+ lff = lff.select(
+ "uid",
+ cs.starts_with(org_type).exclude(
+ f"{org_type}_latitude", f"{org_type}_longitude"
+ ),
+ )
+ lff = lff.group_by(cs.starts_with(org_type)).len("Marchés")
+ return lff.collect()
+
+
def get_departements() -> dict:
with open("data/departements.json", "rb") as f:
data = json.load(f)
@@ -364,7 +394,84 @@ def get_data_schema() -> dict:
return new_schema
+def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
+ """
+ Search in either 'acheteur' or 'titulaire' DataFrame.
+
+ :param dff: Polars DataFrame with acheteur or titulaire columns
+ :param query: User search string
+ :param org_type: 'acheteur' or 'titulaire'
+ :return: Filtered DataFrame with 'matches' column
+ """
+ if not query.strip():
+ return dff.select(pl.lit(False).alias("matches"))
+
+ sleep(0.2)
+
+ # Normalize query
+ normalized_query = unidecode(query.strip()).upper()
+ tokens = [" " + t.strip() for t in normalized_query.split() if t.strip()]
+
+ # Define columns based on entity type
+ cols = [
+ f"{org_type}_id",
+ f"{org_type}_nom",
+ f"{org_type}_departement_nom",
+ f"{org_type}_departement_code",
+ f"{org_type}_commune_nom",
+ ]
+
+ # Concatenate all fields into one string per row
+ org_str = pl.concat_str(pl.lit(" "), pl.col(cols), separator=" ")
+
+ # For each token, create a boolean column: True if token is found
+ token_matches = []
+ for token in tokens:
+ token_match = org_str.str.contains(token).alias(f"token_{token}")
+ token_matches.append(token_match)
+
+ # Count how many tokens match per row
+ match_score = pl.sum_horizontal(token_matches).alias("match_score")
+
+ # For each token, create a boolean column: True if token is found
+ token_matches = []
+ for token in tokens:
+ token_match = org_str.str.contains(token).alias(f"token_{token}")
+ token_matches.append(token_match)
+
+ # Sélection des colonnes
+ if org_type == "acheteur":
+ dff = dff.select(cols + ["Marchés"])
+ if org_type == "titulaire":
+ dff = dff.select(cols + ["Marchés", "titulaire_typeIdentifiant"])
+
+ # Apply and filter
+ dff = (
+ dff.with_columns(token_matches + [match_score])
+ .filter(pl.col("match_score") == len(tokens))
+ .sort("Marchés", descending=True)
+ .drop([f"token_{token}" for token in tokens])
+ )
+
+ # Format result
+ dff = add_links(dff, target="")
+ dff = dff.with_columns(
+ pl.concat_str(
+ pl.col(f"{org_type}_departement_nom"),
+ pl.lit(" ("),
+ pl.col(f"{org_type}_departement_code"),
+ pl.lit(")"),
+ ).alias("Département")
+ )
+
+ dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
+
+ return dff
+
+
df: pl.DataFrame = get_decp_data()
+df_acheteurs = get_org_data(df, "acheteur")
+df_titulaires = get_org_data(df, "titulaire")
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"