"""core/perm_migrate.py — R1's one-time migration: `bus`/`agent` become a PERMANENT FILTER. Owner ruling R1: BU access stops being its own field and becomes an ordinary permanent filter under the permissioning engine, declared and edited exactly like "Agent is Tara". This module builds that filter from the legacy record and — the part that matters — PROVES the swap is a no-op before anything relies on it. ⛔ WHY THE PROOF IS ABOUT VALUES AND NOT ABOUT ROWS (C-PERM amendment 3). `modules/customer_data._pool_build` passes `team_id` into `cust._cust_rev` three times (YTD, LY, LTM) and into `cust._cadence_bulk`. It therefore decides what `rev`, `ly`, `ltm`, `aov`, `est_missed` and the derived `status` MEAN — it is not a row filter. Swap it for a post-filter and a Fisch-only user keeps a plausible row LIST while every number on it quietly becomes Fisch+Royal, worst for `dba = Both` customers, who are precisely the ones a BU filter admits. A pid-set reconciliation reports GREEN on that. So `reconcile()` compares VALUES. The pushdown survives as `perm_scope.derive_pool_scope` — a DERIVATION OF the filter rather than a wall beside it — so a correctly migrated record produces the IDENTICAL pool call, and the reconciliation below should be exact rather than merely close. If it is not exact, that is a finding for the owner, not a tolerance to widen. """ import core.perm_scope as perm_scope import core.perms as perms MODULE = 'customer_data' #: `_dba_attrs` emits exactly {'Fisch','Royal','Both'} (blank = no brand attributable in 24 #: months). A BU grant admits its own brand AND the dual-brand customers: a Both customer IS a #: Fisch customer. Mirrors `perm_scope._DBA_TEAM`, inverted. #: #: ⚠ TWO VALUES, AND THE COLUMN VOCABULARY HAS NO "IS ANY OF". `FILTER_OPS` is single-valued by #: design (`eq`, `neq`, `contains`, …) — `anyOf` belongs to the COHORT vocabulary and is #: deliberately DISJOINT from it, so a column leaf carrying `anyOf` is refused by every engine #: (`filter_eval` returns False, `clean_filter_tree` drops it). This migration's first draft #: emitted exactly that and `reconcile()` caught it as two lost customers. So the BU condition #: is an OR GROUP of `eq` leaves, which is what the condition builder itself produces. _TEAM_DBA = {5: ('Fisch', 'Both'), 6: ('Royal', 'Both')} def filter_for(rec): """The permanent filter equivalent to this record's legacy `bus` + `agent`, or None when the record was unrestricted (an admin, or `bus:'all'` with no agent link). AND-conjoined at the root, and top-level by construction — which is also what makes it pushdown-derivable (`derive_pool_scope` reads top-level AND leaves only). """ nodes = [] team_id = perms.scope_team_id(rec) if team_id in _TEAM_DBA: nodes.append({'conj': 'or', 'children': [{'colId': 'dba', 'op': 'eq', 'value': v, 'value2': ''} for v in _TEAM_DBA[team_id]]}) agent = perms.scope_agent(rec) if agent: nodes.append({'colId': 'agent', 'op': 'eq', 'value': str(agent), 'value2': ''}) if not nodes: return None return {'conj': 'and', 'nodes': nodes} def perms_for(rec, modules=(MODULE,)): """The `perms` block this legacy record becomes. ⚠ ACCESS IS COPIED FROM THE OLD GRANT, NOT ASSUMED. `may_open` is what decided reachability before this wave, so migrating a record must not widen it — and on a migrated record an undeclared module DENIES (amendment 4), so every module the user could reach has to be declared here or the migration is a lockout. """ out = {} for key in modules: out[key] = {'access': bool(perms.may_open(rec, key)), 'filter': filter_for(rec), 'hiddenFields': []} return out def plan(registry_dict, modules=(MODULE,)): """`{username: perms_block}` for every record that is not already migrated. Admins are included and get an all-access block with no filter: they bypass `perm_scope` anyway, but leaving them un-migrated would keep the legacy reader alive for the one account most likely to be inspected when something looks wrong. """ out = {} for uname, rec in (registry_dict or {}).items(): if not isinstance(rec, dict) or perm_scope.is_migrated(rec): continue out[uname] = perms_for(rec, modules) return out #: The row members whose meaning depends on `team_id` — the ones amendment 3 is about. A #: reconciliation that skipped these would be the pid-only gate that cannot see the leak. VALUE_KEYS = ('rev', 'ly', 'ltm', 'aov', 'est_missed', 'status', 'orders', 'last_order') def reconcile(rec, pool_fn, modules=(MODULE,)): """Prove the migrated record sees EXACTLY what the legacy record saw. `pool_fn(agent, team_id) -> rows` is injected so this is testable without Odoo and so the caller decides whether to hit the live builder or a fixture. Returns `{ok, legacy_scope, derived_scope, lost, gained, value_diffs}`: * `lost` — pids the legacy wall admitted and the migrated one drops. Expected to be EMPTY. One narrow class is known and must be REPORTED rather than tolerated: `_dba_attrs` uses a 731-day window while `_cust_rev`'s LY window can reach a little further back, so a customer whose only in-scope revenue predates that window carries a BLANK `dba` and fails a `dba` leaf. Blank is the honest "no brand attributable" state, so such a customer is NAMED here, never rounded away. * `gained` — pids the migrated wall admits and the legacy one did not. Must be empty: it is the widening direction, and there is no acceptable non-empty value. * `value_diffs` — per-pid disagreements on VALUE_KEYS. The assertion amendment 3 exists for; a non-empty list means the pushdown was not applied and the numbers are consolidated. """ legacy_scope = (perms.scope_agent(rec), perms.scope_team_id(rec)) migrated = dict(rec) migrated['perms'] = perms_for(rec, modules) migrated['perms_v'] = perm_scope.PERMS_VERSION team_id, agent = perm_scope.derive_pool_scope(migrated, MODULE) derived_scope = (agent, team_id) legacy_rows = pool_fn(legacy_scope[0], legacy_scope[1]) derived_rows = pool_fn(agent, team_id) fields = _fields_of(legacy_rows) kept = perm_scope.apply_row_scope(derived_rows, migrated, MODULE, fields) legacy_by = {r.get('pid'): r for r in legacy_rows} kept_by = {r.get('pid'): r for r in kept} lost = sorted(set(legacy_by) - set(kept_by)) gained = sorted(set(kept_by) - set(legacy_by)) value_diffs = [] for pid in sorted(set(legacy_by) & set(kept_by)): a, b = legacy_by[pid], kept_by[pid] for k in VALUE_KEYS: if k in a and a.get(k) != b.get(k): value_diffs.append({'pid': pid, 'key': k, 'legacy': a.get(k), 'migrated': b.get(k)}) return {'ok': not lost and not gained and not value_diffs, 'legacy_scope': legacy_scope, 'derived_scope': derived_scope, 'lost': lost, 'gained': gained, 'value_diffs': value_diffs, 'names': {r.get('pid'): r.get('customer') for r in legacy_rows if r.get('pid') in set(lost)}} def _fields_of(rows): """A field list good enough to EVALUATE with, inferred from the assembled rows. The permanent filters this module builds name `dba` (text) and `agent` (text) only, so the inference is exact for them. It is deliberately not a schema: `permits()` needs types to dispatch comparisons, and inferring `text` for an unknown key would be a guess with consequences — so anything not recognised is left out, which makes its leaf unanswerable and therefore DENYING rather than silently mistyped. """ return [{'key': 'dba', 'type': 'text'}, {'key': 'agent', 'type': 'text'}]