| """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' |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _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 |
|
|
|
|
| |
| |
| 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'}] |
|
|