"""Shared helpers for rolling aggregations up the org hierarchy. ``identity_org_memberships.org_path`` is stored as a slash-delimited breadcrumb (e.g. ``"PingCAP / Business Operations / IT Services & Security"``) by the SuccessFactors sync. Every aggregation that used to key on ``person.primary_department`` (a leaf-only name) now keys on each **ancestor** so parent departments show subtree totals alongside direct reports. This module is the single place where the "/" parsing + tenant-root stripping lives so the rules stay consistent across billing / people reports / dashboard summaries. """ from __future__ import annotations from collections.abc import Iterable # SuccessFactors always emits the tenant as the top breadcrumb. It's # useless for grouping — every row has it — so we drop it. If we ever # grow a second tenant that's surfaced in reports, add it here rather # than open-coding it in each aggregation. _TENANT_ROOTS = {"pingcap", "pingcap-ati"} UNASSIGNED_LABEL = "未分配" def parse_org_path(org_path: str | None) -> list[str]: """Split ``org_path`` into an ordered ancestor chain, leaf last.""" if not org_path: return [] parts = [p.strip() for p in org_path.split("/") if p.strip()] if parts and parts[0].lower() in _TENANT_ROOTS: parts = parts[1:] return parts def department_chain( org_path: str | None, leaf_fallback: str | None = None, ) -> list[str]: """Ancestor chain for a single person. If ``org_path`` is missing we fall back to ``leaf_fallback`` (usually the direct org_node_name) so everyone ends up in exactly one bucket rather than silently disappearing. """ parts = parse_org_path(org_path) if parts: return parts if leaf_fallback: return [leaf_fallback] return [UNASSIGNED_LABEL] def rollup_counts( triples: Iterable[tuple[str | None, str | None, int]], ) -> list[dict]: """Roll ``(org_path, leaf_name, count)`` triples up the hierarchy. Returns rows ``{department, parent_department, depth, total_count, direct_count}`` sorted parent-before-child. Useful when the caller only has aggregate counts — e.g. a ``GROUP BY org_node_name`` query where we want the UI to draw a tree. """ buckets: dict[str, dict] = {} for org_path, leaf_name, count in triples: chain = department_chain(org_path, leaf_name) for idx, dep in enumerate(chain): bucket = buckets.setdefault(dep, { "department": dep, "parent_department": chain[idx - 1] if idx > 0 else None, "depth": idx, "total_count": 0, "direct_count": 0, }) bucket["total_count"] += count if idx == len(chain) - 1: bucket["direct_count"] += count return sorted( buckets.values(), key=lambda r: (r["depth"], -r["total_count"]), )