File size: 8,109 Bytes
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""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'}]