File size: 9,473 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 | """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}
# invoices LTM joined by exact invoice_origin (multi-origin merges skipped β counted)
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
# invoice β settled (receivable full-reconcile max counterpart date), LTM invoices
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 = {}
# raw lines + local max β a grouped read here makes the server echo the full id-list
# domain back per group (__domain) and dies with a MemoryError at 5k-id chunks
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)
# survivorship guard: a recent invoice only appears here if it is ALREADY reconciled,
# so the newest cohort is all fast payers. Score only invoices β₯120d old (they've had
# time to be slow); the honest label carries into the UI.
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))
# assemble per-order stage observations
obs = [] # {bu, month, ship_days?, inv_days?}
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 # weight by open AR (+1 floor so
# zero-AR customers still count)
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},
]
|