| """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+'] |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| DTP_KEY = 'ar_days_to_pay' |
| |
| |
| 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 |
| 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]} |
|
|
|
|
| |
| |
| |
| |
| 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 |
| |
| 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, |
| |
| |
| |
| |
| **{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'] |
| |
| |
| |
| |
| 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: |
| 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']) |
| |
| |
| 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']) |
|
|
| |
| 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 = {} |
|
|
| |
| 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: |
| 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: |
| 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 |
|
|
| |
| 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}) |
|
|
| |
| |
| |
| |
| |
| |
| 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}) |
|
|
| |
| |
| |
| |
| |
| |
| 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}) |
|
|
| |
| 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 |
|
|