fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
9.47 kB
"""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},
]