From 6816ba90f4996e7ef6047d05d72d4605235a24b1 Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Wed, 5 Aug 2026 18:50:01 +0000 Subject: [PATCH] =?UTF-8?q?managers:=20normalize=20name=20word-order=20at?= =?UTF-8?q?=20read=20time,=20canonical=20"=D0=A4=D0=B0=D0=BC=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=98=D0=BC=D1=8F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planfix has emitted manager names in two word orders over time ("Имя Фамилия" from the 26 Jul 2026 n8n backfill, "Фамилия Имя" from the ongoing etl_rw feed), so one person appeared as two distinct filter options (e.g. "Александра Столбова" vs "Столбова Александра"), splitting their tasks across two facet values. Fix without touching stored data: - MANAGER_KEY_ARR: SQL expression building a word-order- and case-independent key (lowercase, split, arraySort, rejoin) per manager array element. - MANAGER_COND / MAP_MANAGER_COND now match on that key, so a selected manager catches rows stored under either order. - parse_filters normalizes incoming manager values with the same key (_mkey). - /api/meta returns canonical "Фамилия Имя" names (CANONICAL_MANAGERS), deduplicated; unknown names pass through unchanged. Co-Authored-By: Claude Opus 4.8 --- main.py | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index b78d354..a5ae7d6 100644 --- a/main.py +++ b/main.py @@ -14,12 +14,50 @@ CH_URL = f"http://{CH_HOST}:{CH_PORT}/" app = FastAPI() +# --- Manager name normalization ------------------------------------------- +# Planfix has emitted manager names in two word orders over time ("Имя Фамилия" +# from the 26 Jul 2026 backfill, "Фамилия Имя" from later re-syncs), so a single +# person shows up as two distinct strings (e.g. "Александра Столбова" and +# "Столбова Александра"). We collapse them with a word-order- and case-independent +# key: lowercase, split into words, sort the words, rejoin. The SQL key +# expression (MANAGER_KEY_ARR) and the Python key (_mkey) must stay in sync so a +# selected manager matches rows stored under either order. For display we map the +# key back to the canonical "Фамилия Имя" form in CANONICAL_MANAGERS. +MANAGER_KEY_ARR = ( + "arrayMap(x -> arrayStringConcat(" + "arraySort(splitByChar(' ', lowerUTF8(trimBoth(x)))), ' '), manager)" +) + +# Canonical display names, in the chosen "Фамилия Имя" order. Unknown names fall +# through untouched (see canon_manager), so new managers still appear as-is. +CANONICAL_MANAGERS = [ + "Вербицкий Александр", + "Иванова Диана", + "Кабатов Евгений", + "Колбина Ирина", + "Столбова Александра", +] + + +def _mkey(name: str) -> str: + return " ".join(sorted(name.lower().split())) + + +_MANAGER_CANON = {_mkey(n): n for n in CANONICAL_MANAGERS} + + +def canon_manager(name: str) -> str: + # Map any word order / casing of a known manager to the canonical + # "Фамилия Имя" string; leave unknown names untouched. + return _MANAGER_CANON.get(_mkey(name), name) +# -------------------------------------------------------------------------- + CITY_COND = "(length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)})" DIM_COND = "(length({dimensions:Array(String)}) = 0 OR dimension IN {dimensions:Array(String)})" BRAND_COND = "(length({brands:Array(String)}) = 0 OR brand IN {brands:Array(String)})" # manager is Array(String) in pf_board (a task may have several managers), so we # match with hasAny: keep the row if any selected manager is among its managers. -MANAGER_COND = "(length({managers:Array(String)}) = 0 OR hasAny(manager, {managers:Array(String)}))" +MANAGER_COND = f"(length({{managers:Array(String)}}) = 0 OR hasAny({MANAGER_KEY_ARR}, {{managers:Array(String)}}))" STATUS_COND = "(length({statuses:Array(String)}) = 0 OR task_status IN {statuses:Array(String)})" SEARCH_COND = ( "({search:String} = ''" @@ -45,7 +83,7 @@ MAP_BRAND_COND = ( # booking managed by one of the selected managers. MAP_MANAGER_COND = ( "(length({managers:Array(String)}) = 0" - " OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny(manager, {managers:Array(String)})))" + f" OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny({MANAGER_KEY_ARR}, {{managers:Array(String)}})))" ) # Same idea for the status facet on the map: keep surfaces that have at least one # booking whose task_status is among the selected values. @@ -100,7 +138,9 @@ def parse_filters(city: str, dimension: str, brand: str, manager: str, status: s cities = [c for c in city.split(",") if c] if city else [] dimensions = [d for d in dimension.split(",") if d] if dimension else [] brands = [b for b in brand.split(",") if b] if brand else [] - managers = [m for m in manager.split(",") if m] if manager else [] + # Normalize each selected manager to its word-order-independent key so it + # matches rows stored under either name order (see MANAGER_KEY_ARR). + managers = [_mkey(m) for m in manager.split(",") if m] if manager else [] statuses = [s for s in status.split(",") if s] if status else [] return { "cities": cities, @@ -144,7 +184,7 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st "cities": [r["city"] for r in cities], "dimensions": [r["dimension"] for r in dimensions], "brands": [r["brand"] for r in brands], - "managers": [r["manager"] for r in managers], + "managers": sorted({canon_manager(r["manager"]) for r in managers}), "statuses": [r["task_status"] for r in statuses], }