File size: 16,382 Bytes
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d80995
 
 
 
 
 
 
c3e4cb4
4d80995
c3e4cb4
 
 
 
 
 
 
 
 
 
 
4d80995
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
"""view_templates.py β€” WAVE 23 item 7 (ruling R10, contract C12): the PLATFORM-CURATED
template registry.

WHAT A TEMPLATE IS, precisely, because the word is overloaded: a named bundle of SAVED VIEWS
that a user applies to a database they already have. It does NOT create a table, it does not add
columns, and it does not connect anything. It is the answer to "this database has 30 columns and
I do not know which twelve matter for chasing money" β€” a starting layout, authored once by the
platform, applied by whoever wants it.

β›” THE ONE RULE THAT DECIDES EVERY DESIGN CHOICE HERE β€” `_seed_wave17.py`'s :9-20 law:
**only VIEWS are shared; `fields` and `overlays` are strictly per-user.** So a SHARED view that
filtered on a user-created column would, for every other account, name a column that does not
exist β€” and an unknown column is an INACTIVE leaf in the tri-state filter engine, which is
IGNORED, which WIDENS. A "Collections focus" that quietly showed the whole customer book to
everyone but its author, with nothing going red.

Two consequences, both load-bearing:

  1. **Applying writes the CALLING USER's own views** (`save_view(..., shared=False)`), never
     shared ones. A template is a convenience, not an administrative act, and the moment it
     wrote into the shared bucket it would need an admin wall and a name-collision policy across
     the tenant.
  2. **Every column a template names is checked against the TARGET's live contract before
     anything is written**, and a template whose columns are missing is REFUSED with them named
     β€” never applied partially, never applied with the offending leaf dropped. That check is
     also what makes eligibility honest: `source` below is a label for grouping, the COLUMNS are
     the gate, and the two cannot disagree because only one of them is consulted.

IDEMPOTENT BY PINNED ID. Every view carries `tpl_<template>_<suffix>`, so re-applying updates
the same views instead of minting "Collections focus 2". `save_view` renames on collision, which
would otherwise turn a second click into a second copy.

Pure-ish by construction: `TEMPLATES` and every builder are data, so `verify_home.py` can assert
their shape without a store.
"""

#: The topic a template is FOR. A coarse grouping label used to filter the picker's list; the
#: real gate is `missing_columns` below (a table whose contract has the columns can take the
#: template, whatever we called its source).
SOURCES = ('odoo_customer', 'odoo_product', 'instagram', 'any')

#: The workspace bucket a topic's views live in. `<key>_table_workspace` for user tables
#: (`core/user_tables.py:211` builds the same string when it deletes them), and the two built-ins
#: keep the names their modules have always used.
_WS_KEYS = {
    'customer_data': 'customer_table_workspace',
    'product_data': 'product_table_workspace',
}


def workspace_key(table_key):
    """The `core.table_store` key for a page key, or None if it is not a table at all."""
    key = str(table_key or '')
    if key in _WS_KEYS:
        return _WS_KEYS[key]
    if key.startswith('ut_'):
        return f'{key}_table_workspace'
    return None


def _view(view_id, name, note, visible, *, mode='grid', stack_field=None, sorts=(),
          filters=(), group_by=None, conj='and'):
    """One saved view, in the shape `table_store.save_view` stores and the grid reads.

    ⚠ THE SHAPE IS COPIED FROM `_seed_wave17._view` ON PURPOSE, field for field, including the
    keys that look inert (`widths`, `memberPids`, `rowHeightMode`). The grid's own cleaner fills
    defaults for what is missing, but a view assembled from a DIFFERENT skeleton is a second
    definition of what a view is β€” and the two drift on the day one of them gains a key.

    `permissions.edit` is absent: `table_store.is_shared` reads it, and a view with no
    permissions block is PERSONAL, which is the only thing this file is allowed to write.

    β›” THE MODE LIVES AT `config.display.mode`, AND THE FIRST DRAFT PUT IT AT
    `config.displayMode` β€” A KEY NOTHING READS. The gate caught nothing, because it asserted the
    VALUE against `aios_grid.DISPLAY_MODES` and never the PATH: `"kanban" in DISPLAY_MODES` is
    true wherever you happen to have written it. The Instagram "Review board" would have been
    created as a plain GRID under a name promising a board β€” the view exists, the filter works,
    the kanban never happens, and nothing goes red. Exactly the silent-drop class as a dropped
    filter leaf, one field over. Corrected against `aios_grid._clean_display`, which is now RUN
    over every template in `verify_home` rather than consulted for a vocabulary.

    ⚠ AND `grid` STORES NOTHING. `_clean_display` returns None for it by design ("grid is the
    absent default, so storing it would be a second way to say nothing"), so writing
    `display: {'mode': 'grid'}` would be a key the cleaner strips on the next save β€” a value that
    exists until something touches it.
    """
    display = None
    if mode != 'grid':
        display = {'mode': mode}
        if stack_field:
            # The kanban lane column. `_clean_display` accepts any ref that NAMES a real field
            # and leaves what the mode means to the client, which degrades a wrong-typed ref to
            # its own default rather than erroring.
            display['stackField'] = stack_field
    return {
        'id': view_id, 'name': name, 'kind': 'custom', 'locked': False, 'note': note,
        'config': {
            'filters': list(filters), 'filterConj': conj, 'sorts': list(sorts),
            'groupBy': group_by, 'colorBy': None, 'rowHeightMode': 'short',
            'order': list(visible), 'visible': list(visible), 'widths': {},
            'memberPids': [], **({'display': display} if display else {}),
        },
    }


# ── THE REGISTRY ────────────────────────────────────────────────────────────────────────────
#
# ⚠ FIRST BATCH (C12). Every column named below was checked against the live contract when this
# file was written β€” `aios_grid_fields.json` for customer, `aios_grid.product_fields()` for
# product, `automation_engine.CANDIDATE_FIELDS` for the Instagram candidate pool β€” but the check
# that MATTERS runs at apply time, against the target the user actually picked, because a
# contract can move and a ut_ table's columns are whatever its owner made.

TEMPLATES = [
    {
        'key': 'collections_focus',
        'label': 'Collections focus',
        'desc': 'Everyone with money past due, largest first, with the aging buckets beside it.',
        'source': 'odoo_customer',
        'views': [
            _view(
                'tpl_collections_focus_overdue', 'Past due',
                'Customers with an overdue balance, largest first. The four aging columns sum '
                'to AR overdue; a balance inside the grace period counts as open, not overdue.',
                ['customer', 'agent', 'ar_overdue', 'ar_aged_1_30', 'ar_aged_31_60',
                 'ar_aged_61_90', 'ar_aged_90_plus', 'ar_open', 'days_to_pay',
                 'payment_terms', 'last_order'],
                sorts=[{'colId': 'ar_overdue', 'dir': 'desc'}],
                filters=[{'colId': 'ar_overdue', 'op': 'gt', 'value': '0'}],
            ),
            _view(
                'tpl_collections_focus_worst', 'Over 90 days',
                'The part of the book that is no longer a payment-terms conversation.',
                ['customer', 'agent', 'ar_aged_90_plus', 'ar_overdue', 'ar_exposure',
                 'days_to_pay', 'last_order'],
                sorts=[{'colId': 'ar_aged_90_plus', 'dir': 'desc'}],
                filters=[{'colId': 'ar_aged_90_plus', 'op': 'gt', 'value': '0'}],
            ),
        ],
    },
    {
        'key': 'dba_missing',
        'label': 'DBA is empty',
        # The owner's own example (C12). One view, one job, and it is a DATA-QUALITY view: the
        # brand a customer buys from decides which BU-scoped account can see them at all, so a
        # blank DBA is a customer nobody's book contains.
        'desc': 'Customers with no brand recorded β€” they fall outside every scoped book.',
        'source': 'odoo_customer',
        'alert': True,
        'views': [
            _view(
                'tpl_dba_missing_blank', 'DBA is empty',
                'Customers whose DBA is blank. The brand decides which scoped account sees a '
                'customer, so a blank one is invisible to every book except an unscoped view.',
                # ⚠ SORTED BY OPEN RECEIVABLE, NOT BY SALES. This view first named `sales_ltm`,
                # which DOES NOT EXIST β€” the customer contract carries no LTM sales column (the
                # 32 keys in `aios_grid_fields.json`; `verify_home` caught it against the live
                # contract on the first run). `ar_open` is the nearest real column that answers
                # the same question β€” "which of these matters most" β€” and it answers it in
                # money that is actually outstanding rather than in history.
                ['customer', 'dba', 'agent', 'ar_open', 'last_order', 'days_since'],
                sorts=[{'colId': 'ar_open', 'dir': 'desc'}],
                # ⚠ `isEmpty`, and it is the one op that is safe here. A `select` column's blank
                # is not the empty string in every row shape, and `eq ''` would miss the nulls β€”
                # `isEmpty`/`isNotEmpty` are evaluated type-independently BEFORE any coercion
                # (the same reason `_seed_wave17`'s buy list leans on them).
                filters=[{'colId': 'dba', 'op': 'isEmpty'}],
            ),
        ],
    },
    {
        'key': 'buy_list_companion',
        'label': 'Buy-list companion',
        'desc': 'What is running out, and what it costs to bring in β€” beside the buy list.',
        'source': 'odoo_product',
        'views': [
            _view(
                'tpl_buy_list_companion_cover', 'Cover gap',
                'SKUs whose days of supply is already shorter than their supplier lead time. '
                'Stock columns are consolidated across the one physical warehouse, so this is '
                'empty for a BU-scoped account.',
                ['code', 'product', 'cover_gap_d', 'dos', 'lead_days', 'supplier', 'on_hand',
                 'qty_ltm', 'first_cost', 'origin_country'],
                sorts=[{'colId': 'cover_gap_d', 'dir': 'asc'}],
                # The same four leaves the wave-17 seed uses, and for the same reason: a bare
                # `dos < lead_days` reads a SKU with NO days-of-supply as `0 < 30` (the client
                # engine's `toNum(null)` is 0) and puts every never-selling product on the list.
                filters=[{'colId': 'dos', 'op': 'isNotEmpty'},
                         {'colId': 'lead_days', 'op': 'isNotEmpty'},
                         {'colId': 'lead_days', 'op': 'gt', 'value': '0'},
                         {'colId': 'dos', 'op': 'lt',
                          'rhs': {'kind': 'field', 'colId': 'lead_days'}}],
            ),
            _view(
                'tpl_buy_list_companion_nosupplier', 'No supplier on file',
                'SKUs we cannot reorder because nobody is recorded as selling them to us.',
                ['code', 'product', 'supplier', 'on_hand', 'qty_ltm', 'first_cost'],
                sorts=[{'colId': 'qty_ltm', 'dir': 'desc'}],
                filters=[{'colId': 'supplier', 'op': 'isEmpty'}],
            ),
        ],
    },
    {
        'key': 'ig_candidates',
        'label': 'Candidates review',
        'desc': 'Discovered profiles as a review board, plus the ones worth reading first.',
        'source': 'instagram',
        'views': [
            _view(
                'tpl_ig_candidates_board', 'Review board',
                'Discovered profiles grouped by category. Move a card to say what you '
                'decided about it β€” the automation never decides for you.',
                # ⚠ WAVE 26 (R6): `tracked` was here and the column is DELETED. The owner
                # retired it because the per-automation stage field already tracks a record's
                # progress, and two progress columns that can disagree is worse than one. A
                # template naming a column its contract no longer has is a template that ships
                # broken β€” `verify_home` caught this one, which is the only reason it is not live.
                ['handle', 'full_name', 'followers', 'avg_engagement', 'verified', 'category',
                 'profile_url'],
                # `stackField`, NOT `groupBy` β€” the kanban lane column is a `display` ref, while
                # `groupBy` is the GRID's row grouping. They are two different features that both
                # sound like "group by", and only one of them makes lanes.
                mode='kanban', stack_field='category',
                sorts=[{'colId': 'followers', 'dir': 'desc'}],
            ),
            _view(
                'tpl_ig_candidates_engaged', 'High engagement',
                'Profiles whose audience actually responds, biggest first. Engagement, not '
                'follower count, is what a small account can be good at.',
                ['handle', 'full_name', 'avg_engagement', 'followers', 'category', 'bio',
                 'external_url'],
                sorts=[{'colId': 'avg_engagement', 'dir': 'desc'}],
                filters=[{'colId': 'avg_engagement', 'op': 'gt', 'value': '0'}],
            ),
        ],
    },
]


def get(key):
    """One template by key, or None."""
    return next((t for t in TEMPLATES if t['key'] == str(key)), None)


def columns_named(template):
    """Every column key a template's views reference β€” visible, filtered, sorted, grouped, and
    the right-hand side of a field-vs-field comparison.

    β›” THE `rhs` IS THE ONE PEOPLE FORGET, and `_seed_wave17` learned it the expensive way:
    `clean_filter_tree` KEEPS a leaf whose rhs names an unknown column β€” it drops the rhs and
    leaves the rule β€” so `dos < lead_days` silently becomes `dos < ""`. The leaf count is
    unchanged, so a check that counted leaves would pass while the view answered a different
    question. Naming the rhs here is what makes the eligibility test see it.
    """
    out = set()
    for view in template.get('views') or ():
        cfg = view.get('config') or {}
        out.update(cfg.get('visible') or ())
        for s in cfg.get('sorts') or ():
            if isinstance(s, dict) and s.get('colId'):
                out.add(s['colId'])
        if cfg.get('groupBy'):
            out.add(cfg['groupBy'])
        for f in cfg.get('filters') or ():
            if not isinstance(f, dict):
                continue
            if f.get('colId'):
                out.add(f['colId'])
            rhs = f.get('rhs')
            if isinstance(rhs, dict) and rhs.get('colId'):
                out.add(rhs['colId'])
    return out


def missing_columns(template, field_keys):
    """The columns this template needs that the target does not have. Empty β‡’ it can be applied.

    THE ELIGIBILITY TEST AND THE REFUSAL TEST ARE THE SAME FUNCTION, deliberately: a picker that
    offered a template the apply door would then refuse is a control that lies, and two separate
    predicates is how those two answers drift apart.
    """
    return sorted(columns_named(template) - set(field_keys or ()))


def offer(field_keys, source=None):
    """Every template that CAN be applied to a target with these columns.

    `source` narrows further when the caller knows it (the picker passes the target's own), but
    a template whose columns are all present is offered regardless of label β€” the columns are
    the contract and the label is a grouping.
    """
    out = []
    for t in TEMPLATES:
        if missing_columns(t, field_keys):
            continue
        if source and t['source'] not in ('any', source):
            continue
        out.append({'key': t['key'], 'label': t['label'], 'desc': t['desc'],
                    'source': t['source'], 'views': len(t.get('views') or ()),
                    'alert': bool(t.get('alert'))})
    return out