managers: normalize name word-order at read time, canonical "Фамилия Имя"
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 <noreply@anthropic.com>
This commit is contained in:
parent
c7eae87660
commit
6816ba90f4
48
main.py
48
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],
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user