| """Order-to-Cash timing — where the days hide, by stage, each with its owner: |
| |
| order → ship (warehouse) sale.order.date_order → effective_date (delivery complete) |
| ship → invoice (admin — a FREE DSO leak; links to the DNI list in Data Health) |
| invoice → paid (collections) invoice date → full-reconcile settlement date |
| (same OCA partner_time_to_pay semantics as ar.days_to_pay) |
| |
| Plus the terms-gap rollup: per payment term, contractual days vs $-weighted actual days — |
| "are the TERMS wrong?" — with the stranded free-credit $ it represents (reuses the validated |
| per-customer machinery in modules/ar.days_to_pay: dominant term, excess days, free credit). |
| Medians (not means) per stage — one whale order must not move the story. |
| """ |
| import datetime as dt |
|
|
| import core.odoo as O |
| import core.periods as P |
| import modules.ar as ar |
|
|
|
|
| def _median(v): |
| v = sorted(v) |
| n = len(v) |
| if not n: |
| return None |
| return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2.0 |
|
|
|
|
| def _d(s): |
| s = str(s or '')[:10] |
| try: |
| return dt.date.fromisoformat(s) |
| except ValueError: |
| return None |
|
|
|
|
| def stages(team_id=None, t=None): |
| """Per-BU stage medians + monthly trend over the LTM. Coverage counts are honest: |
| each stage only scores orders where both endpoints exist.""" |
| t = t or P.today() |
| lf, lt = P.ltm(t) |
| ex = O.excluded_partner_ids() |
|
|
| dom = [('state', 'in', ['sale', 'done']), |
| ('date_order', '>=', f'{lf} 00:00:00'), ('date_order', '<=', f'{lt} 23:59:59')] |
| dom.append(('team_id', '=', team_id) if team_id is not None |
| else ('team_id', 'in', O.TEAM_IDS)) |
| if ex: |
| dom.append(('partner_id', 'not in', list(ex))) |
| orders = O.search_read('sale.order', dom, |
| ['name', 'date_order', 'effective_date', 'team_id']) |
| by_name = {o['name']: o for o in orders} |
|
|
| |
| inv_dom = [('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), |
| ('invoice_date', '>=', lf)] |
| if ex: |
| inv_dom.append(('partner_id', 'not in', list(ex))) |
| invs = O.search_read('account.move', inv_dom, |
| ['invoice_origin', 'invoice_date', 'name']) |
| first_inv = {} |
| multi_origin = 0 |
| for iv in invs: |
| og = (iv.get('invoice_origin') or '').strip() |
| if not og: |
| continue |
| if ',' in og: |
| multi_origin += 1 |
| continue |
| d = str(iv.get('invoice_date') or '')[:10] |
| if og in by_name and d and (og not in first_inv or d < first_inv[og]): |
| first_inv[og] = d |
|
|
| |
| pay_dom = [('move_id.move_type', '=', 'out_invoice'), ('move_id.state', '=', 'posted'), |
| ('account_id.account_type', '=', 'asset_receivable'), |
| ('full_reconcile_id', '!=', False), ('date', '>=', lf)] |
| if ex: |
| pay_dom.append(('partner_id', 'not in', list(ex))) |
| rec_lines = O.search_read('account.move.line', pay_dom, |
| ['date', 'full_reconcile_id']) |
| fr_ids = list({O.m2o_id(l['full_reconcile_id']) for l in rec_lines}) |
| settle = {} |
| |
| |
| for i in range(0, len(fr_ids), 5000): |
| for l in O.search_read('account.move.line', |
| [('full_reconcile_id', 'in', fr_ids[i:i + 5000])], |
| ['full_reconcile_id', 'date']): |
| k = O.m2o_id(l.get('full_reconcile_id')) |
| d = str(l.get('date') or '')[:10] |
| if k and d: |
| settle[k] = max(settle.get(k, ''), d) |
| |
| |
| |
| pay_cutoff = (t - dt.timedelta(days=120)).isoformat() |
| pay_days = [] |
| for l in rec_lines: |
| i_d, s_d = _d(l.get('date')), _d(settle.get(O.m2o_id(l['full_reconcile_id']))) |
| if i_d and s_d and s_d >= i_d and i_d.isoformat() <= pay_cutoff: |
| pay_days.append(((s_d - i_d).days, i_d)) |
|
|
| |
| obs = [] |
| for o in orders: |
| od, sd = _d(o.get('date_order')), _d(o.get('effective_date')) |
| bu = O.TEAM_NAMES.get(O.m2o_id(o.get('team_id')), '?') |
| month = str(o.get('date_order') or '')[:7] |
| ship = (sd - od).days if od and sd and sd >= od else None |
| iv = _d(first_inv.get(o['name'])) |
| invd = (iv - sd).days if sd and iv and iv >= sd else None |
| obs.append({'bu': bu, 'month': month, 'ship': ship, 'inv': invd}) |
|
|
| def _roll(rows, key): |
| vals = [r[key] for r in rows if r.get(key) is not None] |
| return _median(vals), len(vals) |
|
|
| by_bu = [] |
| for bu in sorted({r['bu'] for r in obs}): |
| sub = [r for r in obs if r['bu'] == bu] |
| m_ship, n_ship = _roll(sub, 'ship') |
| m_inv, n_inv = _roll(sub, 'inv') |
| by_bu.append({'bu': bu, 'order_to_ship': m_ship, 'n_ship': n_ship, |
| 'ship_to_invoice': m_inv, 'n_inv': n_inv}) |
| m_pay = _median([d for d, _ in pay_days]) |
|
|
| trend = [] |
| months = sorted({r['month'] for r in obs}) |
| for m in months: |
| sub = [r for r in obs if r['month'] == m] |
| s, ns = _roll(sub, 'ship') |
| v, nv = _roll(sub, 'inv') |
| p_vals = [d for d, i_d in pay_days if i_d.isoformat()[:7] == m] |
| if ns >= 10 and s is not None: |
| trend.append({'month': m, 'stage': 'order-to-ship', 'days': s}) |
| if nv >= 10 and v is not None: |
| trend.append({'month': m, 'stage': 'ship-to-invoice', 'days': v}) |
| if len(p_vals) >= 10: |
| trend.append({'month': m, 'stage': 'invoice-to-paid', 'days': _median(p_vals)}) |
|
|
| ship_all, n_ship_all = _roll(obs, 'ship') |
| inv_all, n_inv_all = _roll(obs, 'inv') |
| return { |
| 'by_bu': by_bu, 'pay_median': m_pay, 'n_pay': len(pay_days), 'trend': trend, |
| 'ship_median': ship_all, 'n_ship': n_ship_all, |
| 'inv_median': inv_all, 'n_inv': n_inv_all, |
| 'n_orders': len(orders), 'n_invoices': len(invs), 'multi_origin': multi_origin, |
| 'window': (lf, lt), |
| } |
|
|
|
|
| def terms_gap(t=None): |
| """Per payment term: contractual days vs invoiced-$-weighted actual days-to-pay and the |
| stranded free-credit $ — rolled up from ar.days_to_pay's validated per-customer rows |
| (dominant term per customer).""" |
| dtp = ar.days_to_pay(t, limit=100000) |
| rows = dtp['rows'] if isinstance(dtp, dict) else dtp |
| per = {} |
| for r in rows: |
| term = r.get('terms') or '(none)' |
| eff = r.get('avg_days_ytd') if r.get('avg_days_ytd') is not None else r.get('avg_days') |
| if eff is None: |
| continue |
| e = per.setdefault(term, {'term': term, 'term_days': r.get('term_days') or 0, |
| 'w': 0.0, 'wd': 0.0, 'customers': 0, 'free_credit': 0.0, |
| 'open': 0.0}) |
| inv_w = max(r.get('open') or 0.0, 0.0) + 1.0 |
| |
| e['w'] += inv_w |
| e['wd'] += eff * inv_w |
| e['customers'] += 1 |
| e['free_credit'] += r.get('free_credit') or 0.0 |
| e['open'] += r.get('open') or 0.0 |
| out = [] |
| for e in per.values(): |
| actual = e['wd'] / e['w'] if e['w'] else None |
| out.append({'term': e['term'], 'term_days': e['term_days'], |
| 'actual_days': actual, |
| 'gap_days': (actual - e['term_days']) if actual is not None else None, |
| 'customers': e['customers'], 'open_ar': e['open'], |
| 'free_credit': e['free_credit']}) |
| out.sort(key=lambda x: -(x['free_credit'] or 0)) |
| return out |
|
|
|
|
| def validate(t=None, team_id=None): |
| """Pull-completeness both sides: LTM order count and posted-invoice count vs |
| independent search_count on the same domains.""" |
| t = t or P.today() |
| lf, lt = P.ltm(t) |
| ex = O.excluded_partner_ids() |
| dom = [('state', 'in', ['sale', 'done']), |
| ('date_order', '>=', f'{lf} 00:00:00'), ('date_order', '<=', f'{lt} 23:59:59')] |
| dom.append(('team_id', '=', team_id) if team_id is not None |
| else ('team_id', 'in', O.TEAM_IDS)) |
| if ex: |
| dom.append(('partner_id', 'not in', list(ex))) |
| b = stages(team_id, t) |
| n_orders = O.get_odoo().search_count('sale.order', dom) |
| inv_dom = [('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), |
| ('invoice_date', '>=', lf)] |
| if ex: |
| inv_dom.append(('partner_id', 'not in', list(ex))) |
| n_inv = O.get_odoo().search_count('account.move', inv_dom) |
| return [ |
| {'check': 'LTM orders — pull complete', 'a': b['n_orders'], 'b': n_orders, |
| 'gap': b['n_orders'] - n_orders, 'ok': b['n_orders'] == n_orders}, |
| {'check': 'LTM posted invoices — pull complete', 'a': b['n_invoices'], 'b': n_inv, |
| 'gap': b['n_invoices'] - n_inv, 'ok': b['n_invoices'] == n_inv}, |
| ] |
|
|