File size: 47,250 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
"""Customer List β€” build, filter and save customer lists (replaces My Day, owner IA 2026-07-23).

The page is a customer-list WORKBENCH in two parts:
  1. BUILDER β€” the whole scoped book, one row per customer with a consistent metric surface
     (YTD / LY revenue, at-risk $, cadence, overdue days, estimated missed $ …): filter, sort,
     SELECT customers and add them to a list.
  2. LISTS β€” saved lists whose membership is a visible, editable FORMULA (a rule set over the
     metric columns) plus hand-picked members. 'Call list' and 'Win-back' ship as TEMPLATES β€”
     the exact formulas the old My Day queues used β€” and the user can change or reset them.

Composes already-validated customers-module functions, so the page's counts derive FROM the
lists it renders (rule 8b). ⚠ **This header used to end "no separate validate()" and W30-T33
made that false** β€” see `validate()` below. The claim held only while every column composed an
already-reconciled customers-module function; the ADDRESS family does not, so it needed its own
tie. Persistence: HF store key 'customer_lists'
({username: {list_name: {rules, sort, members, note}}}); templates are virtual until edited.

The user↔agent link (users.py 'agent' field) scopes the pool to that agent's book; users with
no link (owner/CFO/admin) see the whole book.
"""
import core.odoo as O
import core.periods as P
import core.store as store
import core.table_store as table_store
import modules.ar as ar
import modules.customers as cust
import modules.map as map_mod
import modules.sales as S

LIMIT = 500          # honest builder display cap β€” always shown with the full count, never silent
KEY = 'customer_lists'
TABLE_KEY = 'customer_table_workspace'

# ------------------------------------------------------------------ the metric pool
# Field metadata for the rule builder: key -> (label, kind). 'num' fields take >=/<=/>/</=,
# 'text' fields take contains/=. Every field is a column of pool() rows.
FIELDS = {
    'customer':         ('Customer', 'text'),
    'status':           ('Status', 'text'),
    'agent':            ('Agent', 'text'),
    'city':             ('City', 'text'),
    'state':            ('State', 'text'),
    'revenue_ytd':      ('YTD $', 'num'),
    'revenue_ly':       ('LY (same period) $', 'num'),
    'yoy_pct':          ('YoY %', 'num'),
    'at_risk':          ('At risk $', 'num'),
    'ltm_rev':          ('LTM $', 'num'),
    'orders_24m':       ('Orders (24M)', 'num'),
    'aov':              ('Avg order $', 'num'),
    'days_since':       ('Days since last order', 'num'),
    'typical_gap_days': ('Typical gap (days)', 'num'),
    'overdue_days':     ('Overdue vs cadence (days)', 'num'),
    'est_missed':       ('Est. missed $', 'num'),
}
NUM_OPS = ('>=', '<=', '>', '<', '=')
TEXT_OPS = ('contains', '=')


def agent_pids(agent_name):
    """Partner ids of an agent's book (None = unscoped/whole book)."""
    return cust.agent_partner_ids(agent_name) if agent_name else None


#: Wave 17 R2 β€” `ar` bucket label -> the grid's column key. ONE mapping, read by the attribute
#: builder, the empty-row default and validate(), so a bucket cannot exist under two spellings.
AR_BUCKET_FIELDS = {'1-30': 'ar_aged_1_30', '31-60': 'ar_aged_31_60',
                    '61-90': 'ar_aged_61_90', '90+': 'ar_aged_90_plus'}


def _ar_attrs(t=None):
    """{pid: {ar_open, ar_overdue, ar_outstanding, ar_exposure}} β€” the AR family.

    Wave 21 R1: `ar_open` is the NOT-YET-DUE half of a disjoint split (label "AR current $" β€”
    key kept so saved views keep working); `ar_outstanding` is the total (current + overdue),
    which is what "open AR" means everywhere else.

    Composed from `modules/ar.credit_exposure()`, NOT re-derived: it is already reconciled to
    Odoo by `ar.validate()`, so the Customer table and the Collections page cannot disagree about
    what a customer owes. Read from the UNFILTERED `_all_rows` β€” `rows` is a display-truncated
    top-40, and a column built off that would silently blank everybody else.

    ⚠ `credit_limit` is deliberately NOT a column: 20 of 1,548 customers have one. EXPOSURE is
    the number that answers "how much are we out on this customer", and it needs no limit.

    ⚠ `days_to_pay` is NOT COMPUTED here β€” it is READ from a nightly snapshot. MEASURED
    2026-07-27: `ar.credit_exposure()` costs 9s; `ar.days_to_pay()` costs **301s**, because
    settlement date means walking three years of reconciled receivable lines and their
    full-reconcile groups. Computing it in `pool()` would add five minutes to every Customer-table
    build and every Space container start; computing it LAZILY would just move those five minutes
    onto whoever opened the page first. So the expensive half runs on the app's existing
    background store-sync thread (`ar.refresh_days_to_pay_snapshot`) and this reads the answer
    out of the store for nothing. A missing snapshot yields a blank column, never a slow page.
    """
    out = {}
    try:
        exp = ar.credit_exposure(t)
        for r in exp.get('_all_rows') or []:
            out[r['pid']] = {
                'ar_open': r.get('open', 0.0) or 0.0,
                'ar_overdue': r.get('overdue', 0.0) or 0.0,
                # Wave 21 R1 β€” the TOTAL owed. Sum of the two halves ar.credit_exposure already
                # reconciles to Odoo's residual read_group (ar.py validate), so no second oracle.
                'ar_outstanding': (r.get('open', 0.0) or 0.0) + (r.get('overdue', 0.0) or 0.0),
                'ar_exposure': r.get('exposure', 0.0) or 0.0,
                # Wave 17 R2 β€” the aging split, so a saved VIEW can be the collections
                # worklist. Keys mirror `ar.OVERDUE_BUCKETS` through AR_BUCKET_FIELDS; the four
                # sum EXACTLY to `ar_overdue` (gated in validate()).
                **{k: r.get(f'aged_{b}', 0.0) or 0.0
                   for b, k in AR_BUCKET_FIELDS.items()},
            }
    except Exception:
        pass
    for pid, avg in ar.days_to_pay_snapshot().items():
        # A customer with an AR row but no settled invoice in the window keeps `None` β€” blank,
        # not 0. "Pays in 0 days" is a claim, and a false one.
        out.setdefault(pid, {})['days_to_pay'] = avg
    return out


def _mix_attrs(pids=None, t=None):
    """{pid: {top_category, sku_count, top_sku, top_category_pct}} β€” the product-mix family.

    ONE read_group over sale.order.line by (partner, product) across the LTM window, then the
    product->category map, then aggregation in Python. Line grain is the only grain that can
    answer "what does this customer buy", and it is deliberately ALL-CHANNEL scope-wise for the
    same reason inventory analysis is (see [[ri-channel-scope-amazon]]): a customer's product mix
    is a fact about the customer, not about a sales team.

    `top_category_pct` is that category's share of the customer's LTM line revenue β€” the number
    that says whether "top" means dominant or merely first.

    ⚠ There is NO `team_id` parameter, and its absence is the point rather than an omission: for
    a BU-scoped viewer the revenue columns beside this one ARE team-scoped while these are not,
    so "top category" can name products bought through a channel that viewer's revenue figures
    exclude. That is the same trade the inventory modules make, and it is the right one β€” a
    customer's product mix is a fact about the customer.
    """
    t = t or P.today()
    mf, mt = P.ltm(t)
    # ⚠ `pids` NARROWS THE RESULT, NEVER THE DOMAIN β€” and that is not a style choice. Putting
    # the caller's ~1,500 partner ids into an `in` clause on a LINE-grain read_group makes Odoo
    # answer **502 Bad Gateway**; the same query unfiltered returns in 22s. Measured 2026-07-27,
    # after the filtered form took the live Customer page past 20 MINUTES without rendering.
    # The grouped result is per-partner anyway, so dropping the extras in Python costs nothing.
    dom = [('order_id.state', 'in', ['sale', 'done']),
           ('order_id.date_order', '>=', str(mf)), ('order_id.date_order', '<=', str(mt))]
    keep = set(pids) if pids else None
    rows = O.read_group('sale.order.line', dom, ['price_subtotal:sum'],
                        ['order_partner_id', 'product_id'], lazy=False)
    prod_ids = {O.m2o_id(r.get('product_id')) for r in rows if r.get('product_id')}
    prod_ids.discard(None)
    cats = {}
    plist = list(prod_ids)
    for i in range(0, len(plist), 5000):
        for p in O.search_read('product.product', [('id', 'in', plist[i:i + 5000])], ['categ_id']):
            # `categ_id`'s display name is the full PATH ("All / Foams & Finishes / Styrofoam").
            # The column takes the LEAF, because a column and a group label have to be readable β€”
            # and the path's own root ("All") carries no information. ⚠ Two categories that share
            # a leaf name would merge in a group-by; measured against this catalogue that does not
            # happen, and readability is worth more than defending against a rename.
            full = O.m2o_name(p.get('categ_id')) or ''
            cats[p['id']] = (full.split(' / ')[-1].strip() or '(none)') if full else '(none)'

    per = {}
    for r in rows:
        pid = O.m2o_id(r.get('order_partner_id'))
        prod = O.m2o_id(r.get('product_id'))
        if pid is None or prod is None or (keep is not None and pid not in keep):
            continue
        val = r.get('price_subtotal', 0.0) or 0.0
        e = per.setdefault(pid, {'skus': set(), 'by_cat': {}, 'by_sku': {}, 'total': 0.0})
        e['skus'].add(prod)
        e['by_cat'][cats.get(prod, '(none)')] = e['by_cat'].get(cats.get(prod, '(none)'), 0.0) + val
        e['by_sku'][prod] = e['by_sku'].get(prod, 0.0) + val
        e['total'] += val

    top_prod_ids = {max(e['by_sku'], key=e['by_sku'].get) for e in per.values() if e['by_sku']}
    names = {}
    tlist = list(top_prod_ids)
    for i in range(0, len(tlist), 5000):
        for p in O.search_read('product.product', [('id', 'in', tlist[i:i + 5000])], ['name']):
            names[p['id']] = p.get('name') or ''

    out = {}
    for pid, e in per.items():
        cat = max(e['by_cat'], key=e['by_cat'].get) if e['by_cat'] else '(none)'
        sku = max(e['by_sku'], key=e['by_sku'].get) if e['by_sku'] else None
        out[pid] = {
            'top_category': cat,
            'top_category_pct': (e['by_cat'][cat] / e['total']) if e['total'] else None,
            'sku_count': len(e['skus']),
            'top_sku': names.get(sku, '(none)') if sku is not None else '(none)',
        }
    return out


def _salesperson_attrs(pids=None, t=None):
    """{pid: salesperson} β€” the DOMINANT order-taker over LTM, by order count.

    ⚠ Salesperson is NOT the Agent, and they are not interchangeable (owner, 2026-07-27):
    the SALESPERSON is whoever entered and took the order, the AGENT is the person the customer
    is assigned to and who earns the commission β€” including when the customer orders through the
    office and somebody else keys it in. So this reads `sale.order.user_id` (the ORDER's taker,
    fully populated: 20 people over $6.2M LTM) and NOT `res.partner.user_id`, which is a
    different, near-empty field.

    A customer can have several over a year, so the column takes the one who took the MOST of
    their orders β€” a single value the table can group and filter cleanly. Ties break toward the
    larger revenue.
    """
    t = t or P.today()
    mf, mt = P.ltm(t)
    # Same rule as `_mix_attrs`: the pid list narrows the RESULT, not the domain. Order grain is
    # far smaller than line grain so this one never actually 502'd, but a caller's id list has no
    # business being pushed into a query whose grouping already answers per partner.
    keep = set(pids) if pids else None
    dom = S.order_domain(str(mf), str(mt), None)
    rows = O.read_group('sale.order', dom, ['amount_untaxed:sum'],
                        ['partner_id', 'user_id'], lazy=False)
    per = {}
    for r in rows:
        pid = O.m2o_id(r.get('partner_id'))
        if pid is None or (keep is not None and pid not in keep):
            continue
        who = O.m2o_name(r.get('user_id')) or '(none)'
        n, rev = r.get('__count', 0) or 0, r.get('amount_untaxed', 0.0) or 0.0
        cur = per.get(pid)
        if cur is None or (n, rev) > (cur[1], cur[2]):
            per[pid] = (who, n, rev)
    return {pid: v[0] for pid, v in per.items()}


def pool(agent_name=None, team_id=None):
    """One row per customer across the scoped book: the union of YTD buyers, same-period-LY
    buyers and anyone with an order in the 24-month cadence window β€” so lapsed/win-back
    accounts are in the pool, not just YTD actives. Reuses the customers module's validated
    building blocks (_cust_rev / _cadence_bulk / _partner_attrs) so every metric matches the
    Customers page definitions; est_missed is the Call-list formula (min(cycles missed, 3) Γ— AOV)."""
    return _pool_build(agent_name, team_id, limit=None)['rows']


def pool_first(agent_name=None, team_id=None, limit=100):
    """Wave-7 W1 (C1 as amended): the cold-load FAST SLICE β€” the pool's first `limit` rows in
    its own default order + the true total, built from the CHEAP families only (revenue Γ—3,
    cadence, status, est_missed, partner attributes β‰ˆ 12s measured). The AR / product-mix /
    salesperson / coords columns stay BLANK until the full build swaps in: `_mix_attrs` alone
    is a whole-book 20s read_group whose domain must never be pid-narrowed (502 β€” see its
    docstring), so "all columns on 100 rows" measured 44.6s vs 45.1s full β€” no win. A partial
    payload is a UI PHASE, never a reporting basis β€” validate() reconciles the full pool only."""
    return _pool_build(agent_name, team_id, limit=limit, fast=True)


# ------------------------------------------------------------------ the address reconciliation
#: {our pool key: the `res.partner` field an Odoo domain filters on}. The two m2o columns are
#: stored here as RESOLVED NAMES (`O.m2o_name`), so the oracle asks Odoo whether the id is SET
#: while we count whether the name is non-blank β€” a set id resolving to an empty name is the one
#: place those two questions could come apart, and the per-key leg is what would report it.
ADDRESS_FIELDS = {
    'street': 'street', 'street2': 'street2', 'city': 'city',
    'state': 'state_id', 'country': 'country_id', 'zip': 'zip',
}

#: OUR blank. `_partner_attrs` collapses every partner attribute to '(none)' when blank (MECE, so
#: a group-by has no null bucket), a pool row for a partner with NO attrs takes the same sentinel
#: from the row template, and `_reconcile_ledger`'s revived rows are written '(none)' outright.
_ADDRESS_BLANK = ('(none)', '', None)


def address_blank(value):
    """OUR blank predicate, in ONE place β€” the fold and the oracle must ask ONE question.

    ⚠ `.strip()` is part of it, and that is the half that diverges from Odoo. A domain
    `('street', '!=', False)` counts a whitespace-only street as PRESENT while this counts it as
    blank, so the two normalizers would disagree by exactly the whitespace-only population.
    MEASURED 2026-08-12 across `street`/`street2`/`city`/`zip` over the whole pool: **0**. The
    leg that keeps it 0 is asserted in `validate()` rather than assumed
    ([[one-question-two-normalizers]]).
    """
    return value in _ADDRESS_BLANK or (isinstance(value, str) and not value.strip())


def validate(team_id=None):
    """Reconcile the ADDRESS family the Customer grid ships to an independent Odoo aggregate.

    β›” THE POINT OF THIS FUNCTION IS THAT THE NUMBER SURVIVES THE SESSION THAT TOOK IT. W30-T33
    shipped `street`/`street2` after measuring their coverage with a throwaway shell one-liner β€”
    a measurement, never a `validate()` β€” so nothing in the repo re-checked it and the figure
    died with that session. The house law is *every metric gets a `validate()` reconciling to an
    INDEPENDENT Odoo aggregate*, and an oracle nobody re-runs rots exactly like a mode nobody
    runs ([[rules-need-gates]]). `aios-web/api/verify_odoo_relational.py` is what re-runs it.

    ⚠ THE TRAP THAT REDS A CORRECT LEG, and it is why every domain below is scoped to the pool's
    OWN pid set: **the customer pool is the WHOLE BOOK**, not `customer_rank > 0 AND active`
    ([[customer-grid-pool-vs-odoo-count]]). The 3,617/3,478 pair quoted in the wave-30 mailbox is
    the rank>0-active population and is NOT this one; measured here the same day, the pool holds
    3,629 rows of which 3,487 carry a street. Comparing our count against a differently-scoped
    Odoo count is a true measurement of the wrong subject [[measure-the-real-call]].

    β›” IT BUILDS THE FULL `pool()`, NOT `pool_first`/`fast=True`, AND THE COST IS REAL β€” **444 s
    measured 2026-08-12** (the address itself is ~14 s of that; AR, product mix, salesperson,
    coords and DBA are the rest). Reported rather than optimised away, per R6's second sentence.
    `fast=True` is not the shipping basis: it skips `_reconcile_ledger`, so the RETAINED customers
    (I13) never enter and the population is a different one. The recommended fix if this ever has
    to get cheap is a `families=` argument on `_pool_build` so a reconciliation can ask for the
    families it reconciles β€” NOT a second cheaper pool builder, which would be two evaluators for
    one question [[one-evaluator-per-question]].
    """
    rows = pool(team_id=team_id)
    pids = [int(r['pid']) for r in rows if r.get('pid')]
    odoo = O.get_odoo()
    checks = []

    # ── the population, and the three numbers that keep the address legs honest ───────────────
    # An Odoo `search` excludes archived records by default, so `n_visible` counts the partners
    # that are BOTH live and ours. Three populations therefore ride the pool and only one of them
    # can carry an address: rows Odoo can see, rows `_reconcile_ledger` REVIVED (I13 β€” Odoo
    # dropped them; every attribute is written '(none)'), and rows that are neither, i.e. archived
    # in Odoo but never retained. The third is the one that grows silently, so it is REPORTED as
    # a number rather than absorbed into a tolerance.
    revived = [r for r in rows if r.get('odoo_status') != 'Active']
    rpids = [int(r['pid']) for r in revived if r.get('pid')]
    n_visible = odoo.search_count('res.partner', [('id', 'in', pids)])
    # β›” ASSERTED, NOT ASSUMED. "The retained rows are the ones Odoo lost" is trivially true when
    # checked against the ledger that did the retaining β€” that is the self-sealing shape. So it is
    # asked of ODOO: a retained pid Odoo can still see means `_reconcile_ledger` buried a LIVE
    # customer's address behind six '(none)' cells.
    still_live = odoo.search_count('res.partner', [('id', 'in', rpids)]) if rpids else 0
    checks.append({
        'check': "every RETAINED customer (I13) is one Odoo genuinely cannot see β€” asked of "
                 "Odoo, never of the ledger that did the retaining",
        'ours': still_live, 'theirs': 0, 'ok': still_live == 0,
        'detail': {'pool_rows': len(rows), 'odoo_visible': n_visible, 'retained': len(revived),
                   'archived_not_retained': len(pids) - n_visible - len(revived)},
    })

    # ── the six address columns, each against a domain Odoo evaluates server-side ─────────────
    # β›” `search_count` WITH A DOMAIN, never a re-read-and-recount: it makes ODOO do the filtering,
    # so the oracle cannot inherit a bug from `_partner_attrs`, which is the code under test.
    for key, field in ADDRESS_FIELDS.items():
        ours = sum(1 for r in rows if not address_blank(r.get(key)))
        theirs = odoo.search_count('res.partner', [('id', 'in', pids), (field, '!=', False)])
        checks.append({
            'check': f"the grid's `{key}` column is non-blank on exactly the pool customers Odoo "
                     f"says carry `{field}` β€” an INDEPENDENT search_count over the pool's own "
                     f"pid set, never a count of the rows we just built",
            'ours': ours, 'theirs': theirs, 'ok': ours == theirs,
            # `column` is what the GATE matches on. Substring-matching the prose is how a leg
            # silently stops being checked when somebody rewords it, and this file's own twin
            # (`verify_product_pool`) matches on prose precisely because it had no key to use.
            'detail': {'column': key, 'odoo_field': field, 'blank_here': len(rows) - ours},
        })

    # ── the two normalizers are ONE question, and this is the leg that keeps them one ─────────
    # Everything above compares OUR `.strip() or '(none)'` against ODOO'S `!= False`. Those agree
    # on every value except a whitespace-only one, which Odoo reports present and this module
    # renders blank. Measured 0 today; if it ever stops being 0 the per-key legs above go red for
    # a reason that has nothing to do with the address being wrong, so the cause is named HERE.
    text_keys = [k for k, f in ADDRESS_FIELDS.items() if f == k]
    ws = {}
    if pids:
        # `O.search_read`, the same door `_partner_attrs` reads through β€” the client's own method
        # takes `domain=`/`fields=` as keywords and this wrapper is the one with a fixed shape.
        for got in O.search_read('res.partner', [('id', 'in', pids)], text_keys):
            for k in text_keys:
                v = got.get(k)
                if isinstance(v, str) and v and not v.strip():
                    ws[k] = ws.get(k, 0) + 1
    checks.append({
        'check': "no pool customer carries a WHITESPACE-ONLY address value β€” the one input on "
                 "which our blank predicate and Odoo's `!= False` provably disagree",
        'ours': sum(ws.values()), 'theirs': 0, 'ok': not ws,
        'detail': {'per_field': ws, 'fields_checked': text_keys},
    })
    return checks


def _dba_attrs(pids, t):
    """{pid: 'Fisch' | 'Royal' | 'Both'} β€” which brand(s) a customer's confirmed orders carry
    over the pool's own 24-month universe (C-DBA, wave 2026-08-02).

    GIFTWARE DEALS (the Amazon channel) is deliberately NOT a DBA β€” a customer buying only
    through it stays blank here, exactly as ARCHITECTURE.md Β§4's wholesale scope treats that
    team. Blank also covers the ledger-retained customers whose activity predates the window:
    blank means "no brand attributable in 24 months", never a guess. One read_group, grouped
    (partner, team); degrades to {} like the other attr families β€” a blank column beats a
    page that cannot render.
    """
    try:
        import datetime as _dt
        d_from = (_dt.date.fromisoformat(str(t)) - _dt.timedelta(days=731)).isoformat()
        pairs = O.read_group(
            'sale.order',
            [('state', 'in', ('sale', 'done')), ('date_order', '>=', d_from),
             ('partner_id', 'in', list(pids)), ('team_id', 'in', (5, 6))],
            ['partner_id'], ['partner_id', 'team_id'], lazy=False)
        teams = {}
        for r in pairs:
            pid = O.m2o_id(r.get('partner_id'))
            tid = O.m2o_id(r.get('team_id'))
            if pid and tid in (5, 6):
                teams.setdefault(pid, set()).add(tid)
        label = {5: 'Fisch', 6: 'Royal'}
        return {pid: ('Both' if len(ts) == 2 else label[next(iter(ts))])
                for pid, ts in teams.items()}
    except Exception:
        return {}


def _book_pids(team_id=None, agent_pids=None):
    """⭐ WAVE 20 (owner items 8 + 27, ruling R3) β€” THE WHOLE BOOK, not just who bought recently.

    Owner: *"Martin Pasternak showing only 313 accounts when I filter agent, whereas in Odoo it is
    494 … I want our App to show complete source of truth, even if no sale.order at all or not.
    Its important to see what customer is getting assigned to Martin or whether we can retarget
    them."* The pool was a 24-MONTH SALES universe (YTD βˆͺ LY-YTD βˆͺ cadence-window buyers), so a
    customer assigned to an agent who has never ordered β€” precisely a retargeting target β€” did not
    exist in the app at all.

    R3's definition, MEASURED against live Odoo 2026-08-05:
        customer_rank>0 active .............. 3,614
        ever ordered (confirmed, team 5/6) ...  1,748
        UNION ............................... 3,723   (vs 1,555 in the old 24-month pool)
    Martin's book under this rule is **494** β€” the owner's Odoo number, to the account.

    β›” **THE "agent-assigned" LEG OF R3 IS DELIBERATELY NOT A THIRD TERM, and that is a
    measurement, not a shortcut.** Taken literally it added 174 partners and pushed Martin to 503;
    every one of the 9 extras on his book was an ODOO ADDRESS RECORD rather than an account β€”
    `type` in (`delivery`, `other`), most carrying a `parent_id`, and TWO with `name: False`.
    Shipping them would have put nameless rows in the customer table and made "how many customers
    does Martin have" answer 503 against an Odoo screen that says 494.
    Excluding address types wholesale is also wrong (`type not in (delivery, invoice, other,
    private)` measured **488** β€” it drops 6 genuine accounts that happen to carry a delivery
    type). `customer_rank > 0` is the predicate that means "this partner is a customer record",
    it reproduces the owner's number exactly, and every agent-assigned ACCOUNT already satisfies
    it β€” so the leg is subsumed rather than dropped. Item 27's actual ask (the 126 assigned
    partners with no `sale.order` at all) is fully served: they are rank>0 and they are in.

    ⚠ ACTIVE ONLY. Archived partners stay out (R3): they are ex-customers, and putting them in
    every count would make "how many customers do we have" unanswerable. The agent-LOGIN scope
    (`customers.agent_partner_ids`) deliberately still includes archived β€” an agent's own book is
    their whole history β€” and the two remain compatible because that set is INTERSECTED with this
    pool, so archived rows drop out of the table without narrowing the agent's own permissions.

    ⚠ BU SCOPE IS APPLIED THROUGH ORDERS, NOT THROUGH THE PARTNER. `res.partner` carries no team,
    so a BU-scoped caller gets the partners who have ORDERED in that BU (plus their own agent
    book). Widening it to every partner for a scoped user would cross the BU isolation rule that
    the whole permissioning model rests on.
    """
    try:
        if team_id:
            # Scoped: partners with confirmed orders in THIS BU, ever. `read_group` on partner_id
            # rather than a search_read of orders β€” the group is the distinct set, and it is one
            # round trip instead of paging tens of thousands of order rows.
            rows = O.read_group('sale.order',
                                [('state', 'in', ('sale', 'done')), ('team_id', '=', team_id)],
                                ['partner_id'], ['partner_id'], lazy=False)
            book = {O.m2o_id(r.get('partner_id')) for r in rows}
            book.discard(None)
        else:
            rank = O.search_read('res.partner',
                                 [('customer_rank', '>', 0), ('active', '=', True)],
                                 ['id'], limit=200000)
            # `ever` is NOT redundant with rank>0: a partner can be archived-then-reactivated, or
            # have had its rank reset, and an account that demonstrably bought from us belongs in
            # the book whatever its flags say now. An ORDER is a fact; a rank is a setting.
            rows = O.read_group('sale.order',
                                [('state', 'in', ('sale', 'done')), ('team_id', 'in', (5, 6))],
                                ['partner_id'], ['partner_id'], lazy=False)
            ever = {O.m2o_id(r.get('partner_id')) for r in rows}
            ever.discard(None)
            book = {r['id'] for r in rank} | ever
        # An agent filter NARROWS the book to that agent's partners β€” never widens it. The
        # intersection is what keeps an agent-login user inside their own book while still
        # gaining every no-order account assigned to them, which is the whole point of item 27.
        return (book & set(agent_pids)) if agent_pids is not None else book
    except Exception as e:
        # Degrade to the sales-derived universe rather than failing the page β€” but LOUDLY.
        #
        # β›” THIS DEGRADATION IS NOT LIKE THE OTHERS IN THIS MODULE, and the difference is what
        # makes silence wrong here. Every other family (`_ar_attrs`, `_mix_attrs`, …) degrades to
        # a blank COLUMN, which is visible: the user sees an empty column and asks why. This one
        # degrades to absent ROWS. The pool still builds from the sales legs, so the page renders
        # perfectly, the counts look plausible, and Martin is quietly back at 313 with nothing
        # anywhere saying the book leg failed. An invisible degradation of a COUNT is the failure
        # mode this codebase keeps writing rules against ([[no-unverifiable-aggregates]]).
        try:
            import harness.telemetry as _tel
            _tel.error('customer_data:_book_pids', e, fallback='sales-derived pool only')
        except Exception:
            pass
        print(f"[aios] WARNING customer_data._book_pids failed ({type(e).__name__}: {e}) - the "
              f"customer pool is falling back to the 24-month SALES universe, so accounts with "
              f"no recent orders (owner item 27) are MISSING from this build.")
        return set()


def _pool_build(agent_name, team_id, limit, fast=False):
    t = P.today()
    yf, yt = P.ytd(t)
    lf, lt = P.ytd_last_year(t)
    mf, mt = P.ltm(t)
    pids = agent_pids(agent_name)
    this = cust._cust_rev(yf, yt, team_id, pids)
    last = cust._cust_rev(lf, lt, team_id, pids)
    ltm = cust._cust_rev(mf, mt, team_id, pids)
    cad = cust._cadence_bulk(t, team_id, agent_pids=pids)
    all_pids = set(this) | set(last) | set(cad) | _book_pids(team_id, pids)
    total = len(all_pids)
    if limit is not None and total > limit:
        # the SAME ordering the assembled pool ships (see the sort below) β€” the slice must be
        # the first page of the very list the full build renders, or the swap-in reshuffles.
        ranked = sorted(all_pids,
                        key=lambda p: -((this.get(p) or {}).get('rev', 0.0)
                                        + (last.get(p) or {}).get('rev', 0.0)))
        all_pids = set(ranked[:limit])
    attrs = cust._partner_attrs(list(all_pids))
    if fast:
        # C1 fast phase: the slow families stay empty β€” their columns render blank behind the
        # toolbar's partial indicator until the full build swaps in.
        ar_a, mix_a, sp_a, geo_a, dba_a = {}, {}, {}, {}, {}
    else:
        # The three families the owner asked for (2026-07-27). Each degrades to {} rather than
        # raising: a customer table that cannot render because AR is momentarily unreachable is
        # a worse failure than one with a blank column.
        ar_a = _ar_attrs(t)
        mix_a = _mix_attrs(all_pids, t)
        sp_a = _salesperson_attrs(all_pids, t)
        # W11 (wave-7): coordinates for the Map VIEW β€” Odoo coords else the geocode cache,
        # read-only reuse (coords_for never geocodes); missing β†’ null lat/lon, and the
        # client's map view discloses the count.
        geo_a = map_mod.coords_for(list(all_pids))
        # C-DBA (2026-08-02): the brand attribute rides the full build only, like the rest.
        dba_a = _dba_attrs(all_pids, t)
    # customers whose only activity sits in the older half of the 24-month cadence window
    # appear in no revenue map β€” fetch their names directly (else the lapsed cohort, exactly
    # the win-back targets this union exists for, would render as '?')
    unnamed = [pid for pid in all_pids
               if not ((this.get(pid) or {}).get('name') or (last.get(pid) or {}).get('name')
                       or (ltm.get(pid) or {}).get('name'))]
    extra_names = ({r['id']: r['name'] for r in
                    O.search_read('res.partner', [('id', 'in', unnamed)], ['name'])}
                   if unnamed else {})
    rows = []
    for pid in all_pids:
        tv = this.get(pid) or {}
        lv = last.get(pid) or {}
        c = cad.get(pid) or {}
        rev, ly = tv.get('rev', 0.0), lv.get('rev', 0.0)
        gap = c.get('typical_gap_days')
        overdue = c.get('overdue_days')
        aov = c.get('aov', 0.0)
        est = (min(overdue / gap, 3.0) * aov) if (gap and overdue and overdue > 0) else 0.0
        status = ('New' if rev > 0 and ly <= 0 else
                  'Lost' if ly > 0 and rev <= 0 else
                  'Declining' if 0 < rev < ly else
                  'Growing' if rev > 0 else 'Dormant')
        a = attrs.get(pid) or {}
        rows.append({
            'pid': pid,
            'customer': tv.get('name') or lv.get('name')
                        or (ltm.get(pid) or {}).get('name') or extra_names.get(pid) or '?',
            'status': status,
            'agent': a.get('agent', '(none)'),
            # ⭐ W30-T33 (carried W29-T51) β€” the street lines beside the city they belong to.
            'street': a.get('street', '(none)'), 'street2': a.get('street2', '(none)'),
            'city': a.get('city', '(none)'),
            # C-DBA: blank (not '(none)') β€” a select's blank is its own honest empty state.
            'dba': dba_a.get(pid, ''),
            'state': a.get('state', '(none)'),
            # Owner 2026-07-27, the partner-attribute family. `.get(..., default)` is not
            # defensive habit here: search_read returned 1,548 of 1,550 pool pids β€” two partners
            # in the sales history are archived or deleted β€” so a customer with NO attrs row is
            # a real case, and it must render as '(none)' rather than KeyError the whole page.
            'country': a.get('country', '(none)'), 'zip': a.get('zip', '(none)'),
            'payment_terms': a.get('payment_terms', '(none)'),
            'customer_since': a.get('customer_since', ''),
            # Row-level datum for the `created_time` field type (wave-5 item 11) β€” rides every
            # row like pid, no column of its own until a user creates one.
            '_created': a.get('created_at', ''),
            'tags': a.get('tags', '(none)'), 'pricelist': a.get('pricelist', '(none)'),
            # AR / credit exposure β€” composed from the already-reconciled ar.py blocks, so the
            # Customer table and the Collections page cannot disagree.
            'ar_open': (ar_a.get(pid) or {}).get('ar_open', 0.0),
            'ar_overdue': (ar_a.get(pid) or {}).get('ar_overdue', 0.0),
            'ar_outstanding': (ar_a.get(pid) or {}).get('ar_outstanding', 0.0),
            'ar_exposure': (ar_a.get(pid) or {}).get('ar_exposure', 0.0),
            'days_to_pay': (ar_a.get(pid) or {}).get('days_to_pay'),
            **{k: (ar_a.get(pid) or {}).get(k, 0.0) for k in AR_BUCKET_FIELDS.values()},
            # Product mix, LTM, line grain, ALL-channel.
            'top_category': (mix_a.get(pid) or {}).get('top_category', '(none)'),
            'top_category_pct': (mix_a.get(pid) or {}).get('top_category_pct'),
            'sku_count': (mix_a.get(pid) or {}).get('sku_count', 0),
            'top_sku': (mix_a.get(pid) or {}).get('top_sku', '(none)'),
            # The ORDER-TAKER, not the agent. Different people, different questions.
            'salesperson': sp_a.get(pid, '(none)'),
            # W11: nullable β€” the Map view pins what it can and counts what it cannot.
            'lat': (geo_a.get(pid) or {}).get('lat'),
            'lon': (geo_a.get(pid) or {}).get('lon'),
            'revenue_ytd': rev, 'revenue_ly': ly,
            'yoy_pct': P.yoy_pct(rev, ly),
            'at_risk': max(ly - rev, 0.0),
            'ltm_rev': (ltm.get(pid) or {}).get('rev', 0.0),
            'orders_24m': c.get('n_orders', 0), 'aov': aov,
            'last_order': c.get('last_order', ''),
            'days_since': c.get('days_since'),
            'typical_gap_days': gap, 'overdue_days': overdue,
            'est_missed': est,
        })
    for r in rows:
        r['odoo_status'] = 'Active'
    # I13 (owner item 13, contract C6): RETAIN customers that disappear from Odoo.
    # Only the FULL build reconciles the ledger β€” the fast slice is 100 rows by construction,
    # so letting it write would mark ~1,450 living customers archived on every cold load.
    if not fast:
        rows, total = _reconcile_ledger(rows, total, agent_name, team_id)
    rows.sort(key=lambda r: -(r['revenue_ytd'] + r['revenue_ly']))
    return {'rows': rows, 'total': total}


LEDGER_KEY = 'customer_ledger'


def _reconcile_ledger(rows, total, agent_name, team_id):
    """Remember every customer we have ever seen, and keep serving the ones Odoo stopped
    returning (owner item 13: "if it is deleted, that it goes to an archived tag immediately β€”
    so stored in our platform even if deleted in Odoo").

    Why a ledger at all: this pool is built from SALES HISTORY, not from a partner list, so a
    partner deleted in Odoo usually keeps appearing (their order lines remain) β€” until the day
    the lines go too, when the row would silently vanish along with every note, tag and cohort
    membership attached to it. Nothing else in the app would notice. The ledger is the only
    record that the customer ever existed, which is why it starts recording now rather than
    when the surfacing is finished: history you did not write down is not recoverable later.

    ⚠ SCOPE-SAFE. The ledger is written ONLY from an UNSCOPED build (no agent, no BU). A scoped
    build legitimately sees a fraction of the book, and reconciling from it would mark every
    customer outside that scope 'Archived' for everyone β€” the failure would look exactly like
    the data loss this is meant to prevent. Scoped builds READ the ledger and resurrect nothing
    they cannot prove they should see.
    """
    try:
        ledger = dict(store.get(LEDGER_KEY) or {})
    except Exception:
        return rows, total                  # a ledger we cannot read must not break the table
    today = str(P.today())
    live = {r['pid'] for r in rows}
    unscoped = agent_name is None and team_id is None

    if unscoped:
        for r in rows:
            e = ledger.get(str(r['pid'])) or {}
            e.update({'name': r['customer'], 'last_seen': today})
            e.setdefault('first_seen', today)
            ledger[str(r['pid'])] = e

    # Rows the ledger knows and this build did not return. On an unscoped build that means
    # "gone from Odoo"; on a scoped one it usually just means "not in this agent's book", so
    # only the unscoped build may surface them.
    revived = []
    if unscoped:
        for key, e in ledger.items():
            try:
                pid = int(key)
            except (TypeError, ValueError):
                continue
            if pid in live:
                continue
            revived.append({
                'pid': pid, 'customer': e.get('name') or '?', 'status': 'Dormant',
                'odoo_status': 'Archived',
                # Everything else is genuinely UNKNOWN now β€” the record is gone. Blank is the
                # honest rendering; carrying the last-known numbers forward would state figures
                # as current that nothing can reconcile ([[no-unverifiable-aggregates]]).
                # ⚠ W30-T33: the address keys land HERE TOO, and this is the site a field addition
                # silently skips. `rows_from_pool` projects with `r.get(k)`, so a revived row
                # missing a key renders BLANK rather than raising β€” archived customers would show
                # an empty address column while live ones showed a full one, and nothing anywhere
                # would report it. Two row templates, one field contract.
                'agent': '(none)', 'street': '(none)', 'street2': '(none)',
                'city': '(none)', 'state': '(none)', 'country': '(none)',
                'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
                '_created': '', 'tags': '(none)', 'pricelist': '(none)',
                'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
                'days_to_pay': None,
                **{k: 0.0 for k in AR_BUCKET_FIELDS.values()},
                'top_category': '(none)', 'top_category_pct': None, 'sku_count': 0,
                'top_sku': '(none)', 'salesperson': '(none)', 'lat': None, 'lon': None,
                'revenue_ytd': 0.0, 'revenue_ly': 0.0, 'yoy_pct': None, 'at_risk': 0.0,
                'ltm_rev': 0.0, 'orders_24m': 0, 'aov': 0.0, 'last_order': '',
                'days_since': None, 'typical_gap_days': None, 'overdue_days': None,
                'est_missed': 0.0,
            })
        if store.available():
            try:
                store.update(LEDGER_KEY, lambda cur: {**cur, **ledger}, flush='async')
            except Exception:
                pass                        # recording is best-effort; the table is not
    return rows + revived, total + len(revived)


# ------------------------------------------------------------------ the rule engine
# A list's formula = [{'field','op','value'}, ...] over FIELDS + a 'sort' key ('-x' = desc).
# The two templates ARE the old My Day queue formulas, expressed as visible rules.
#
# 'note' is a SEED, not the source of truth (owner 2026-07-25). It is what a list reads
# BEFORE anyone edits it; the moment a user saves a description the stored one wins, and
# an empty stored description stays empty β€” the template must never resurrect. That
# precedence lives in aios_grid.views_from_defs (a saved view replaces its template-derived
# one wholesale) and is pinned by _qa_grid_view_note.py. Edit the prose here only to change
# what a NEW user starts with.
TEMPLATES = {
    'Call list': {
        'rules': [{'field': 'orders_24m', 'op': '>=', 'value': 3},
                  {'field': 'overdue_days', 'op': '>', 'value': 0},
                  {'field': 'days_since', 'op': '<=', 'value': 365}],
        'sort': '-est_missed',
        'note': 'Customers overdue against their OWN reorder cadence (3+ orders, a real gap '
                'pattern, ordered within 365d), ranked by estimated missed revenue.'},
    'Win-back': {
        'rules': [{'field': 'revenue_ly', 'op': '>=', 'value': 2000},
                  {'field': 'at_risk', 'op': '>', 'value': 0}],
        'sort': '-at_risk',
        'note': 'Bought materially last year (over $2,000), down or gone this year, ranked by '
                'dollars at risk.'},
}


def _match(row, rule):
    """One rule against one row. None/missing numeric values fail every numeric comparison
    (a customer with no cadence is never 'overdue'); text ops are case-insensitive."""
    field, op, val = rule.get('field'), rule.get('op'), rule.get('value')
    if field not in FIELDS:
        return True                                   # unknown field β†’ rule is inert, not a crash
    v = row.get(field)
    if FIELDS[field][1] == 'text':
        s, q = str(v or '').lower(), str(val or '').lower()
        return q in s if op == 'contains' else s == q
    try:
        v, val = float(v), float(val)
    except (TypeError, ValueError):
        return False
    return {'>=': v >= val, '<=': v <= val, '>': v > val, '<': v < val,
            '=': v == val}.get(op, False)


def apply_rules(rows, rules, sort=None):
    """Filter the pool through a formula (AND of all rules) and apply the list's sort."""
    out = [r for r in rows if all(_match(r, ru) for ru in (rules or []))]
    if sort:
        key = sort.lstrip('-')
        out.sort(key=lambda r: (r.get(key) is None,
                                -(r.get(key) or 0) if sort.startswith('-') else (r.get(key) or 0))
                 if FIELDS.get(key, ('', 'num'))[1] == 'num'
                 else str(r.get(key) or '').lower(), reverse=False)
    return out


# ------------------------------------------------------------------ persistence (HF store)
def saved_lists(username):
    """{list_name: {'rules','sort','members','note'}} for one user ({} when none / store down)."""
    try:
        return (store.get(KEY) or {}).get(username, {}) or {}
    except Exception:
        return {}


def save_list(username, name, definition):
    def _up(d):
        d.setdefault(username, {})[name] = definition
        return d
    store.update(KEY, _up)


def delete_list(username, name):
    def _up(d):
        (d.get(username) or {}).pop(name, None)
        return d
    store.update(KEY, _up)


# ------------------------------------------------------------------ Airtable-style table workspace
# This is intentionally separate from `customer_lists`: a LIST owns membership/formula
# semantics, while a VIEW owns presentation/query state (filters, multi-sort, grouping,
# visible fields, widths, color, row height). List views can reference the same formula
# without conflating the two persistence contracts.
#
# The store logic MOVED to core/table_store.py 2026-07-27 (the table-page factory): these
# names are the Customer table's instance of the generic per-object workspace store, kept as
# module functions so every existing caller (app.py's host loop, QA gates) is untouched.
# A NEW table object gets its own `core.table_store.make('<its>_table_workspace')`.
_TSTORE = table_store.make(TABLE_KEY)

#: Wave 16 C-TOPIC β€” the ops OBJECT, for callers that select a table by TOPIC (grid_events'
#: `EventCtx.table`). The module-level re-exports below stay for every existing caller.
TABLE_OPS = _TSTORE

table_workspace = _TSTORE.workspace
shared_table_views = _TSTORE.shared_views      # wave-9 I17: views shared WITH this viewer
shared_table_view = _TSTORE.shared_view        #   ...and one raw, for authorisation only
save_table_view = _TSTORE.save_view
delete_table_view = _TSTORE.delete_view
save_table_field = _TSTORE.save_field
delete_table_field = _TSTORE.delete_field
duplicate_table_field = _TSTORE.duplicate_field
patch_table_overlay = _TSTORE.patch_overlay
save_table_folders = _TSTORE.save_folders
save_table_active_view = _TSTORE.save_active_view   # owner item 3: last-opened view, per user
save_table_record_layout = _TSTORE.save_record_layout  # 2026-08-02 C-LAYOUT: record-detail order


# ------------------------------------------------------------------ digest compatibility
def queues(agent_name=None, team_id=None, limit=200):
    """The two canonical morning lists (call list + win-back) for the daily digest email β€”
    unchanged formulas via the customers module (the digest always uses the CANONICAL templates,
    not a user's edited copy, so every rep's email means the same thing)."""
    pids = agent_pids(agent_name)
    return {
        'calls': cust.contact_recommendations(team_id=team_id, limit=limit, agent_pids=pids),
        'risk': cust.at_risk(team_id=team_id, limit=limit, agent_pids=pids),
    }