File size: 25,903 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
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
"""Collections / AR module β€” receivables, aging, DSO, exposures, follow-up status,
and the reconciliation gap (open-invoice total vs Odoo's partner receivable).

AR is company-level (account.move), not team-scoped. Excluded accounts removed. Aging is built
from open customer invoices/credit notes (signed residual nets credit notes), so buckets
sum to the open-doc AR total. DSO uses LTM gross invoiced sales.
"""
import sys
import math
import datetime as dt
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import core.odoo as O
import core.periods as P
import core.store as store

BUCKETS = ['Current', '1-30', '31-60', '61-90', '90+']

#: Store key for the NIGHTLY days-to-pay snapshot (owner, 2026-07-27).
#:
#: `days_to_pay()` costs ~300s β€” it walks three years of reconciled receivable lines and their
#: full-reconcile groups to find each invoice's SETTLEMENT date. That is fine for a page somebody
#: opens on purpose, and unacceptable inside `customer_data.pool()`, which every Customer-table
#: render and every Space container start depends on. So the expensive half runs on a schedule
#: and the table reads the ANSWER.
#:
#: Shape: {'computed': 'YYYY-MM-DD HH:MM', 'years': 3, 'days': {'<pid>': avg_days|None}}.
#: JSON object keys are strings, which is why the reader coerces back to int.
DTP_KEY = 'ar_days_to_pay'
#: Refresh when the snapshot is older than this. 20h rather than 24 so a daily cadence never
#: skips a day by drifting a few minutes later each time.
DTP_MAX_AGE_HOURS = 20


def days_to_pay_snapshot():
    """`{pid: avg_days}` from the last snapshot β€” CHEAP, and it never raises.

    Returns `{}` when the snapshot is absent, unreadable or malformed. A blank column is the
    correct degradation: the alternative is a Customer table that will not render because a
    derived statistic is missing, and `days_to_pay` is a statistic, not a fact the page is about.
    """
    try:
        snap = store.get(DTP_KEY) or {}
        days = snap.get('days') or {}
        out = {}
        for k, v in days.items():
            try:
                out[int(k)] = None if v is None else float(v)
            except (TypeError, ValueError):
                continue
        return out
    except Exception:
        return {}


def days_to_pay_computed_at():
    """When the snapshot was taken ('' if there is none) β€” so the column can say how old it is."""
    try:
        return str((store.get(DTP_KEY) or {}).get('computed') or '')
    except Exception:
        return ''


def refresh_days_to_pay_snapshot(max_age_hours=DTP_MAX_AGE_HOURS, t=None, force=False):
    """Recompute + persist the snapshot IF it is stale. Returns what it did, and never raises.

    Called from the app's existing background store-sync thread, so the ~300s cost is paid off
    the render path by whichever container happens to be awake. Deliberately NOT called from
    `pool()`: a lazy compute would just move the five minutes onto whoever opened the page first.

    ⚠ Writes through `store.put` on a key nothing else owns. It is a DERIVED artifact β€” losing it
    costs one recompute and nothing else, which is why it is safe to overwrite wholesale rather
    than read-modify-write.
    """
    if not store.available():
        return {'skipped': 'no store'}
    try:
        if not force:
            at = days_to_pay_computed_at()
            if at:
                age = dt.datetime.now() - dt.datetime.strptime(at, '%Y-%m-%d %H:%M')
                if age < dt.timedelta(hours=max_age_hours):
                    return {'skipped': 'fresh', 'age_h': round(age.total_seconds() / 3600, 1)}
    except Exception:
        pass                       # an unparseable stamp means "recompute", never "crash"
    try:
        res = days_to_pay(t)
        days = {str(r['pid']): r.get('avg_days') for r in (res.get('_all_rows') or [])}
        store.put(DTP_KEY, {'computed': dt.datetime.now().strftime('%Y-%m-%d %H:%M'),
                            'years': 3, 'days': days})
        return {'refreshed': len(days)}
    except Exception as e:
        return {'error': str(e)[:200]}


#: The OVERDUE buckets, in order β€” `BUCKETS` minus 'Current'. Wave 17 R2 gives each of these a
#: column on the Customer grid, so the vocabulary must have exactly one home: a second list
#: spelled '31_60' somewhere else is how two surfaces start disagreeing about what "60 days"
#: means. Derived from BUCKETS rather than retyped.
OVERDUE_BUCKETS = [b for b in BUCKETS if b != 'Current']


def _bucket(days):
    if days <= 0:
        return 'Current'
    if days <= 30:
        return '1-30'
    if days <= 60:
        return '31-60'
    if days <= 90:
        return '61-90'
    return '90+'


def _open_docs(t=None):
    o = O.get_odoo()
    dom = [('move_type', 'in', ['out_invoice', 'out_refund']), ('state', '=', 'posted'),
           ('payment_state', 'in', ['not_paid', 'partial'])]
    ex = O.excluded_partner_ids()
    if ex:
        dom.append(('partner_id', 'not in', list(ex)))
    docs = o.search_read('account.move', dom,
                         ['name', 'partner_id', 'move_type', 'invoice_date', 'invoice_date_due',
                          'amount_total', 'amount_residual_signed'])
    today = t or P.today()
    for d in docs:
        due = d.get('invoice_date_due')
        try:
            d['days_overdue'] = max((today - dt.date.fromisoformat(due)).days, 0) if due else 0
        except Exception:
            d['days_overdue'] = 0
        d['bucket'] = _bucket(d['days_overdue'])
        d['open'] = d.get('amount_residual_signed') or 0.0
    return docs


def _ltm_invoiced(t=None):
    """LTM gross invoiced sales (out_invoice βˆ’ out_refund, amount_total) for DSO."""
    o = O.get_odoo()
    lf, lt = P.ltm(t)
    base = [('state', '=', 'posted'), ('invoice_date', '>=', lf), ('invoice_date', '<=', lt)]
    ex = O.excluded_partner_ids()
    if ex:
        base.append(('partner_id', 'not in', list(ex)))
    inv = O.sum_field('account.move', base + [('move_type', '=', 'out_invoice')], 'amount_total')
    ref = O.sum_field('account.move', base + [('move_type', '=', 'out_refund')], 'amount_total')
    return inv - ref


def summary(t=None):
    docs = _open_docs(t)
    total_ar = sum(d['open'] for d in docs)
    overdue = sum(d['open'] for d in docs if d['days_overdue'] > 0)
    ltm_sales = _ltm_invoiced(t)
    dso = (total_ar / (ltm_sales / 365.0)) if ltm_sales else None
    # reconciliation gap vs Odoo partner receivable (company-level)
    ex = set(O.excluded_partner_ids())
    partner_recv = sum(p['credit'] for p in O.search_read('res.partner',
                       [('credit', '>', 0)] + ([('id', 'not in', list(ex))] if ex else []), ['credit']))
    n_customers = len({O.m2o_id(d['partner_id']) for d in docs})
    return {
        'total_ar_open': total_ar,
        'overdue': overdue,
        'pct_overdue': (overdue / total_ar * 100) if total_ar else 0,
        'dso_days': dso,
        'ltm_invoiced': ltm_sales,
        'open_customers': n_customers,
        'partner_receivable': partner_recv,
        'reconciliation_gap': partner_recv - total_ar,
    }


def aging(t=None):
    docs = _open_docs(t)
    out = {b: 0.0 for b in BUCKETS}
    for d in docs:
        out[d['bucket']] += d['open']
    total = sum(out.values()) or 1.0
    return [{'bucket': b, 'amount': out[b], 'pct': out[b] / total * 100} for b in BUCKETS]


def top_exposures(t=None, limit=30):
    docs = _open_docs(t)
    by = {}
    for d in docs:
        pid = O.m2o_id(d['partner_id'])
        nm = O.m2o_name(d['partner_id'])
        e = by.setdefault(pid, {'customer': nm, 'open': 0.0, 'overdue': 0.0, 'oldest': 0, 'docs': 0})
        e['open'] += d['open']
        e['docs'] += 1
        if d['days_overdue'] > 0:
            e['overdue'] += d['open']
            e['oldest'] = max(e['oldest'], d['days_overdue'])
    rows = sorted(by.values(), key=lambda x: -x['overdue'])
    return rows[:limit]


def followup_status_mix(t=None):
    """Mix of res.partner.followup_status among customers carrying receivable.
    followup_status is a non-stored computed field, so we count client-side."""
    ex = set(O.excluded_partner_ids())
    dom = [('credit', '>', 1)] + ([('id', 'not in', list(ex))] if ex else [])
    parts = O.search_read('res.partner', dom, ['followup_status'])
    counts = {}
    for p in parts:
        s = (p.get('followup_status') or 'β€”').replace('_', ' ')
        counts[s] = counts.get(s, 0) + 1
    return [{'status': k, 'customers': v} for k, v in
            sorted(counts.items(), key=lambda kv: -kv[1])]


def reconciliation_flags(t=None, limit=30, threshold=50.0):
    """Customers whose Odoo receivable (credit) diverges from their open-doc total β€”
    indicates unapplied payments/credits. Surfaced during the collections work."""
    docs = _open_docs(t)
    open_by = {}
    for d in docs:
        pid = O.m2o_id(d['partner_id'])
        open_by[pid] = open_by.get(pid, 0.0) + d['open']
    ex = set(O.excluded_partner_ids())
    parts = O.search_read('res.partner',
                          [('credit', '>', 0)] + ([('id', 'not in', list(ex))] if ex else []),
                          ['name', 'credit'])
    rows = []
    for p in parts:
        gap = (p['credit'] or 0) - open_by.get(p['id'], 0.0)
        if abs(gap) > threshold:
            rows.append({'customer': p['name'], 'odoo_receivable': p['credit'],
                         'open_docs': open_by.get(p['id'], 0.0), 'gap': gap})
    rows.sort(key=lambda x: -abs(x['gap']))
    return rows[:limit]


def credit_exposure(t=None, grace_days=5, limit=40):
    """Forward credit exposure per customer (OCA account_financial_risk semantics, re-implemented):
    draft invoices + open-not-overdue + overdue-past-grace residuals + confirmed-uninvoiced order
    value, vs the partner credit limit (when set). The number to check BEFORE accepting the next
    PO from a shaky dealer. Company-level, GIFTWARE excluded."""
    docs = _open_docs(t)
    ex = O.excluded_partner_ids()
    exdom = [('partner_id', 'not in', list(ex))] if ex else []

    per = {}

    def _e(pid, name):
        return per.setdefault(pid, {'pid': pid, 'customer': name, 'draft': 0.0, 'open': 0.0,
                                    'overdue': 0.0, 'uninvoiced': 0.0, 'credit_limit': 0.0,
                                    # Wave 17 R2 β€” the AGING SPLIT of `overdue`, per partner.
                                    # Same loop, same documents, no extra Odoo call: this is the
                                    # decomposition the Customer grid needs to REPLACE the
                                    # Collections page ("who has money in 90+" is the whole job).
                                    **{f'aged_{b}': 0.0 for b in OVERDUE_BUCKETS}})

    for d in docs:
        e = _e(O.m2o_id(d['partner_id']), O.m2o_name(d['partner_id']))
        if d['days_overdue'] > grace_days:
            e['overdue'] += d['open']
            # ⚠ BUCKETED ONLY PAST THE GRACE PERIOD, deliberately β€” so the four buckets SUM
            # EXACTLY to `overdue` and the grid's columns reconcile to its own total. Using
            # `_bucket` on every doc instead would file days 1..grace under '1-30' while
            # `overdue` excluded them, and the decomposition would be quietly short.
            e[f'aged_{_bucket(d["days_overdue"])}'] += d['open']
        else:
            e['open'] += d['open']

    for r in O.search_read('account.move',
                           [('move_type', 'in', ['out_invoice', 'out_refund']),
                            ('state', '=', 'draft')] + exdom,
                           ['partner_id', 'amount_total_signed']):
        if r.get('partner_id'):
            _e(O.m2o_id(r['partner_id']), O.m2o_name(r['partner_id']))['draft'] += \
                r.get('amount_total_signed') or 0.0

    uexdom = [('order_partner_id', 'not in', list(ex))] if ex else []
    for g in O.read_group('sale.order.line',
                          [('order_id.state', 'in', ['sale', 'done']),
                           ('untaxed_amount_to_invoice', '!=', 0)] + uexdom,
                          ['untaxed_amount_to_invoice'], ['order_partner_id']):
        p = g.get('order_partner_id')
        if p:
            _e(O.m2o_id(p), O.m2o_name(p))['uninvoiced'] += \
                g.get('untaxed_amount_to_invoice') or 0.0

    try:  # credit_limit only exists/means something when the feature is enabled β€” degrade quietly
        for r in O.search_read('res.partner', [('id', 'in', list(per.keys()))], ['credit_limit']):
            if per.get(r['id']) is not None:
                per[r['id']]['credit_limit'] = r.get('credit_limit') or 0.0
    except Exception:
        pass

    rows_all = []
    for e in per.values():
        e['exposure'] = e['draft'] + e['open'] + e['overdue'] + e['uninvoiced']
        e['headroom'] = (e['credit_limit'] - e['exposure']) if e['credit_limit'] else None
        e['flag'] = bool(e['credit_limit']) and e['exposure'] > e['credit_limit']
        rows_all.append(e)
    rows_all.sort(key=lambda x: -x['exposure'])
    # Display rows drop ~zero exposures; _all_rows keeps EVERY customer (incl. net-credit
    # balances) β€” validation sums must run over the unfiltered set or the filter biases them.
    rows = [r for r in rows_all if abs(r['exposure']) > 1]
    return {'rows': rows[:limit], 'n_flagged': sum(1 for r in rows if r['flag']),
            'total_exposure': sum(r['exposure'] for r in rows),
            'grace_days': grace_days, '_all_rows': rows_all}


def days_to_pay(t=None, years=3, limit=30):
    """Per-customer average days from invoice to SETTLEMENT (OCA partner_time_to_pay semantics):
    settlement date = the date the receivable line became fully reconciled (max counterpart line
    date in its full-reconcile group), NOT the payment document date. Windows: lifetime (bounded
    to `years`), last calendar year, this year β€” by invoice date. Joined with current open AR so
    the table reads 'who owes us AND how do they behave'."""
    o = O.get_odoo()
    today = t or P.today()
    since = (today - dt.timedelta(days=365 * years)).isoformat()
    ex = O.excluded_partner_ids()
    dom = [('move_id.move_type', '=', 'out_invoice'), ('move_id.state', '=', 'posted'),
           ('account_id.account_type', '=', 'asset_receivable'),
           ('full_reconcile_id', '!=', False), ('date', '>=', since)]
    if ex:
        dom.append(('partner_id', 'not in', list(ex)))
    inv_lines = O.search_read('account.move.line', dom,
                              ['partner_id', 'date', 'full_reconcile_id', 'move_id'])

    # Payment-term days per term id: prefer the term lines' `days`; fall back to parsing the name.
    term_days = {}
    try:
        terms = O.search_read('account.payment.term', [], ['name'])
        tl = O.search_read('account.payment.term.line', [], ['payment_id', 'days'])
        by_term = {}
        for l in tl:
            k = O.m2o_id(l.get('payment_id'))
            if k is not None:
                by_term[k] = max(by_term.get(k, 0), l.get('days') or 0)
        import re as _re
        for tm in terms:
            d = by_term.get(tm['id'])
            if d is None:
                m = _re.search(r'(\d+)', tm['name'] or '')
                d = int(m.group(1)) if m and 'day' in (tm['name'] or '').lower() else 0
            term_days[tm['id']] = {'name': tm['name'], 'days': d}
    except Exception:
        term_days = {}

    # Per-invoice terms + totals (12m window for the free-credit estimate).
    y365_iso = (today - dt.timedelta(days=365)).isoformat()
    move_ids = list({O.m2o_id(l['move_id']) for l in inv_lines if l.get('move_id')})
    move_info = {}
    for i in range(0, len(move_ids), 5000):
        for mv in O.search_read('account.move', [('id', 'in', move_ids[i:i + 5000])],
                                ['invoice_payment_term_id', 'amount_total', 'invoice_date',
                                 'partner_id']):
            move_info[mv['id']] = mv

    fr_ids = list({O.m2o_id(l['full_reconcile_id']) for l in inv_lines
                   if l.get('full_reconcile_id')})
    settle = {}
    for i in range(0, len(fr_ids), 5000):
        chunk = fr_ids[i:i + 5000]
        try:  # grouped max-date per reconcile (one call per chunk)
            for g in O.read_group('account.move.line',
                                  [('full_reconcile_id', 'in', chunk)],
                                  ['date:max'], ['full_reconcile_id']):
                k = O.m2o_id(g.get('full_reconcile_id'))
                d = g.get('date') or g.get('date:max') or ''
                d = str(d)[:10]
                if k and d:
                    settle[k] = max(settle.get(k, ''), d)
        except Exception:  # fallback: raw lines
            for l in o.search_read('account.move.line',
                                   [('full_reconcile_id', 'in', chunk)],
                                   ['full_reconcile_id', 'date']):
                k = O.m2o_id(l['full_reconcile_id'])
                d = str(l.get('date') or '')[:10]
                if k and d:
                    settle[k] = max(settle.get(k, ''), d)

    y0 = dt.date(today.year, 1, 1).isoformat()
    ly0 = dt.date(today.year - 1, 1, 1).isoformat()
    per = {}
    violations = 0
    for l in inv_lines:
        k = O.m2o_id(l.get('full_reconcile_id'))
        inv_d = str(l.get('date') or '')[:10]
        pay_d = settle.get(k, '')
        if not inv_d or not pay_d:
            continue
        days = (dt.date.fromisoformat(pay_d) - dt.date.fromisoformat(inv_d)).days
        if days < 0:
            violations += 1
            continue
        pid = O.m2o_id(l['partner_id'])
        e = per.setdefault(pid, {'pid': pid, 'customer': O.m2o_name(l['partner_id']),
                                 'all': [], 'ly': [], 'ytd': []})
        e['all'].append(days)
        if inv_d >= y0:
            e['ytd'].append(days)
        elif inv_d >= ly0:
            e['ly'].append(days)

    open_by = {}
    for d in _open_docs(t):
        pid = O.m2o_id(d['partner_id'])
        oe = open_by.setdefault(pid, {'open': 0.0, 'overdue': 0.0})
        oe['open'] += d['open']
        if d['days_overdue'] > 0:
            oe['overdue'] += d['open']

    def _avg(v):
        return (sum(v) / len(v)) if v else None

    # Terms + 12m invoiced per partner (dominant term by invoiced $) β†’ compliance/free-credit.
    pt_terms, pt_inv12 = {}, {}
    for mid, mv in move_info.items():
        pid = O.m2o_id(mv.get('partner_id'))
        tid = O.m2o_id(mv.get('invoice_payment_term_id'))
        amt = mv.get('amount_total') or 0.0
        d = pt_terms.setdefault(pid, {})
        d[tid] = d.get(tid, 0.0) + amt
        if str(mv.get('invoice_date') or '')[:10] >= y365_iso:
            pt_inv12[pid] = pt_inv12.get(pid, 0.0) + amt

    rows = []
    total_free_credit = 0.0
    for pid, e in per.items():
        ob = open_by.get(pid, {'open': 0.0, 'overdue': 0.0})
        a_ly, a_ytd = _avg(e['ly']), _avg(e['ytd'])
        a_all = _avg(e['all'])
        dom_tid = max(pt_terms.get(pid, {None: 0}).items(), key=lambda kv: kv[1])[0]
        tinfo = term_days.get(dom_tid, {'name': '(none)', 'days': 0})
        eff = a_ytd if a_ytd is not None else a_all
        excess = max(0.0, (eff or 0) - tinfo['days']) if eff is not None else None
        inv12 = pt_inv12.get(pid, 0.0)
        free_credit = (excess / 365.0 * inv12) if excess else 0.0
        total_free_credit += free_credit or 0.0
        rows.append({'pid': pid, 'customer': e['customer'], 'open': ob['open'], 'overdue': ob['overdue'],
                     'paid_invoices': len(e['all']), 'avg_days': a_all,
                     'avg_days_ly': a_ly, 'avg_days_ytd': a_ytd,
                     'improvement_days': (a_ly - a_ytd) if (a_ly is not None and a_ytd is not None) else None,
                     'terms': tinfo['name'], 'term_days': tinfo['days'],
                     'excess_days': excess, 'free_credit': free_credit,
                     'invoiced_12m': inv12})
    rows.sort(key=lambda x: -x['open'])
    return {'rows': rows[:limit], 'n_customers': len(per), 'violations': violations,
            'since': since, 'total_free_credit': total_free_credit,
            '_rowsum_inv12': sum(pt_inv12.values()), '_all_rows': rows}


def credit_limit_proposals(exposure, behavior):
    """PROPOSED credit limits for customers trading on open terms with NO limit set (only 22 of
    3,595 partners have one β€” the field is effectively unmaintained). Trade-credit heuristic:
    limit β‰ˆ (12m invoiced / 365) Γ— (term days + 30 review buffer), tiered by observed payment
    behavior (pays within terms +5d β†’ Γ—1.25 Β· chronic 15d+ overrun β†’ Γ—0.75), rounded UP to $500,
    floor $1,000. READ-ONLY: an export worklist for the owner to enter in Odoo β€” once limits are
    in, the exposure table's breach flag and Odoo's own credit hold both come alive."""
    beh = {r['pid']: r for r in behavior.get('_all_rows', []) if r.get('pid')}
    out = []
    for e in exposure.get('_all_rows', []):
        if e.get('credit_limit') or e.get('exposure', 0) <= 0:
            continue
        b = beh.get(e['pid'])
        if not b or (b.get('invoiced_12m') or 0) <= 0:
            continue
        base = b['invoiced_12m'] / 365.0 * ((b.get('term_days') or 0) + 30)
        exd = b.get('excess_days')
        tier = ('on-time' if (exd is not None and exd <= 5)
                else ('slow' if (exd or 0) > 15 else 'normal'))
        mult = {'on-time': 1.25, 'normal': 1.0, 'slow': 0.75}[tier]
        prop = max(1000.0, math.ceil(base * mult / 500.0) * 500.0)
        out.append({'pid': e['pid'], 'customer': e['customer'],
                    'invoiced_12m': b['invoiced_12m'], 'term_days': b.get('term_days') or 0,
                    'excess_days': exd, 'tier': tier, 'exposure': e['exposure'],
                    'proposed_limit': prop,
                    'over_proposed': e['exposure'] > prop})
    out.sort(key=lambda r: -r['exposure'])
    return {'rows': out, 'n': len(out),
            'n_over': sum(1 for r in out if r['over_proposed']),
            'over_value': sum(r['exposure'] - r['proposed_limit']
                              for r in out if r['over_proposed']),
            'with_limit': [e for e in exposure.get('_all_rows', []) if e.get('credit_limit')]}


def validate(t=None, exposure=None, behavior=None):
    """exposure/behavior: pass precomputed credit_exposure()/days_to_pay() results to avoid
    recomputing the heavy pulls when the caller (the page bundle) already has them; validate.py
    calls with no args and computes everything itself."""
    docs = _open_docs(t)
    checks = []
    total = sum(d['open'] for d in docs)
    bucket_sum = sum(a['amount'] for a in aging(t))
    checks.append({
        'check': 'AR aging: Ξ£(buckets) == total open AR',
        'a': round(bucket_sum, 2), 'b': round(total, 2),
        'gap': round(bucket_sum - total, 2),
        'ok': abs(bucket_sum - total) <= 1.0})

    # WAVE 17 R2 β€” the aging SPLIT must decompose the overdue total exactly. These four numbers
    # became COLUMNS on the Customer grid this wave (the Collections view is built on them), so
    # a bucket that drifted from `overdue` would be a wrong number on a worklist somebody works
    # from. Ξ£(buckets) == Ξ£(overdue) is exact by construction (same loop, same documents) and is
    # asserted rather than assumed, because "by construction" is what every drift was before it
    # happened.
    ce_buckets = exposure or credit_exposure(t)
    bkt = sum(r.get(f'aged_{b}', 0.0) for r in ce_buckets['_all_rows']
              for b in OVERDUE_BUCKETS)
    od = sum(r['overdue'] for r in ce_buckets['_all_rows'])
    checks.append({
        'check': 'AR aging split: Ξ£(1-30, 31-60, 61-90, 90+) == Ξ£(overdue)',
        'a': round(bkt, 2), 'b': round(od, 2),
        'gap': round(bkt - od, 2),
        'ok': abs(bkt - od) <= 0.01})

    exp_sum = sum(e['open'] for e in top_exposures(t, limit=10**9))
    checks.append({
        'check': 'AR: Ξ£(per-customer open) == total open AR',
        'a': round(exp_sum, 2), 'b': round(total, 2),
        'gap': round(exp_sum - total, 2),
        'ok': abs(exp_sum - total) <= 1.0})

    # Credit exposure: per-customer (open + overdue) row-build vs an independent read_group sum
    # of amount_residual_signed over the same unpaid-invoice domain. The two sides are separate
    # RPC snapshots on a LIVE ledger β€” a payment applied between them shifts the total β€” so the
    # sides are pulled adjacently and the check carries an explicit live-drift tolerance. A
    # structural bug (double-count, dropped partner, wrong bucket) shows as a %-level gap, far
    # beyond it.
    ex = O.excluded_partner_ids()
    dom = [('move_type', 'in', ['out_invoice', 'out_refund']), ('state', '=', 'posted'),
           ('payment_state', 'in', ['not_paid', 'partial'])]
    if ex:
        dom.append(('partner_id', 'not in', list(ex)))
    resid = O.sum_field('account.move', dom, 'amount_residual_signed')
    ce = exposure or credit_exposure(t)
    ce_open = sum(r['open'] + r['overdue'] for r in ce['_all_rows'])
    tol = max(500.0, abs(resid) * 0.001)
    checks.append({
        'check': 'Exposure: Ξ£(open+overdue per customer) == read_group Ξ£ residual (live-drift tol)',
        'a': round(ce_open, 2), 'b': round(resid, 2),
        'gap': round(ce_open - resid, 2),
        'ok': abs(ce_open - resid) <= tol})

    # Days-to-pay: settlement can never precede the invoice line date, and coverage exists.
    dtp = behavior or days_to_pay(t)
    checks.append({
        'check': 'Days-to-pay: settlement>=invoice violations == 0 (and customers covered > 0)',
        'a': dtp['violations'], 'b': 0,
        'gap': dtp['violations'],
        'ok': dtp['violations'] == 0 and dtp['n_customers'] > 0})
    return checks