loopable / platform /modules /collections_exec.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
15.5 kB
"""Collections executive module — the LIVE Biweekly Executive Collection Report.
Replicates the Excel "Royal Imports - Collection Report - Biweekly Executive" (Summary tab time-series
+ Overdue (FFS)/(RI) lists) but computed live from Odoo (read-only).
AR as-of any date D = the receivable-account LEDGER balance: Σ balance of posted receivable move lines
dated <= D (customer payments post a credit to the receivable account on the payment date, so the
running account balance IS the AR). Validated to ~1% of the hardcoded report; today's value ties to
Σ amount_residual exactly. BU split is by the CUSTOMER (res.partner.team_id 5=Fisch/FFS, 6=Royal/RI),
NOT the invoice team (most invoices carry a generic team). Current aging uses exact per-invoice
amount_residual; historical aging is a FIFO approximation conserved toward the netting total (the
report's exact buckets come from Odoo's specific reconciliation, which the API cannot reconstruct —
account.partial.reconcile.max_date errors server-side). READ-ONLY.
"""
import sys
import datetime as dt
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import core.odoo as O
import core.periods as P
FFS, RI = 5, 6
BU_OF = {FFS: 'FFS', RI: 'RI'}
AGE = ['At Date', '01-30', '31-60', '61-90', '91-120', '>120']
# Overdue-list aging (the report folds not-due + 0-30 days into "At Date"; overdue starts at 31 days).
AGE_LIST = ['At Date', '31-60', '61-90', '91-120', '>120']
# Cash doesn't settle same-day, so the report's "as of" / ending period lags real-time by this many
# days: the last few days of receipts haven't cleared in the bank yet and would understate collections
# and overstate receivables. The ending period is therefore today - SETTLE_OFFSET_DAYS (real-time).
SETTLE_OFFSET_DAYS = 7
# Targets from the report (col U). (goal_value, 'lower'|'higher' is-better)
GOALS = {
'recv_FFS': (427708.0, 'lower'), 'recv_RI': (150789.0, 'lower'),
'dso': (30.0, 'lower'), 'dso_FFS': (30.0, 'lower'), 'dso_RI': (30.0, 'lower'),
'odpct_FFS': (10.0, 'lower'), 'odpct_RI': (10.0, 'lower'), 'odpct_total': (10.0, 'lower'),
}
def _d(s):
try:
return dt.date.fromisoformat(str(s)[:10])
except Exception:
return None
def _as_of(t=None):
"""The report's as-of / ending-period date: real-time minus the settlement lag (default), or an
explicit override if a caller passes one. Using plain 'today' would understate the most recent
receipts (cash takes ~SETTLE_OFFSET_DAYS to clear), so the ending period is offset back by it."""
return t or (P.today() - dt.timedelta(days=SETTLE_OFFSET_DAYS))
def _bucket(days):
if days <= 0:
return 'At Date'
if days <= 30:
return '01-30'
if days <= 60:
return '31-60'
if days <= 90:
return '61-90'
if days <= 120:
return '91-120'
return '>120'
def _lbucket(days):
"""Overdue-list bucket — not-due or within 30 days is 'At Date'; overdue starts at 31."""
if days <= 30:
return 'At Date'
if days <= 60:
return '31-60'
if days <= 90:
return '61-90'
if days <= 120:
return '91-120'
return '>120'
def periods(t=None, n=14, step=14):
"""n biweekly periods ending at t (latest last). Each {label, end(date), beg(date), year, month}."""
t = _as_of(t)
out = []
for i in range(n):
end = t - dt.timedelta(days=step * i)
out.append({'label': end.strftime('%m/%d'), 'end': end, 'beg': end - dt.timedelta(days=step - 1),
'year': end.year, 'month': end.month})
return list(reversed(out))
def pull(t=None):
"""The heavy one-time read: receivable ledger (invoice lines + reduction lines) + partner master.
Records are normalized to plain dicts with parsed date strings + the customer's BU."""
rec = [('account_id.account_type', '=', 'asset_receivable'), ('parent_state', '=', 'posted')]
inv = O.search_read('account.move.line',
rec + [('move_id.move_type', 'in', ['out_invoice', 'out_refund'])],
['date', 'date_maturity', 'balance', 'amount_residual', 'partner_id'], limit=300000)
red = O.search_read('account.move.line',
rec + [('move_id.move_type', 'not in', ['out_invoice', 'out_refund'])],
['date', 'balance', 'partner_id'], limit=300000)
ex = set(O.excluded_partner_ids())
pids = sorted({O.m2o_id(r['partner_id']) for r in inv + red
if r.get('partner_id') and O.m2o_id(r['partner_id']) not in ex})
parts = {p['id']: p for p in O.search_read('res.partner', [('id', 'in', pids)],
['name', 'team_id', 'user_id', 'property_payment_term_id'])}
def bu(pid):
return BU_OF.get(O.m2o_id(parts.get(pid, {}).get('team_id')))
I, R = [], []
for r in inv:
pid = O.m2o_id(r['partner_id'])
if pid in ex or pid not in parts:
continue
I.append({'pid': pid, 'bu': bu(pid), 'date': str(r.get('date') or '')[:10],
'due': str(r.get('date_maturity') or r.get('date') or '')[:10],
'bal': r.get('balance') or 0.0, 'resid': r.get('amount_residual') or 0.0})
for r in red:
pid = O.m2o_id(r['partner_id'])
if pid in ex or pid not in parts:
continue
R.append({'pid': pid, 'bu': bu(pid), 'date': str(r.get('date') or '')[:10],
'bal': r.get('balance') or 0.0})
return {'inv': I, 'red': R, 'parts': parts,
'pulled_at': dt.datetime.now().strftime('%Y-%m-%d %H:%M')}
# ---------------------------------------------------------------- as-of-date primitives
def _recv_by_partner(data, D):
"""AR netting as-of D, per partner = Σ(invoice + reduction balances dated <= D)."""
bal = defaultdict(float)
for r in data['inv']:
if r['date'] <= D:
bal[r['pid']] += r['bal']
for r in data['red']:
if r['date'] <= D:
bal[r['pid']] += r['bal']
return bal
def _agg_bu(by_partner, data):
out = {'FFS': 0.0, 'RI': 0.0, 'total': 0.0}
for pid, v in by_partner.items():
bu = BU_OF.get(O.m2o_id(data['parts'].get(pid, {}).get('team_id')))
if bu:
out[bu] += v
out['total'] += v
return out
def _aging(data, D, recv=None, exact=False):
"""Aging buckets ($) by BU as-of D, + overdue total by BU. exact=True uses live per-invoice
amount_residual (current period); else FIFO (payments applied oldest-due-first). When `recv`
(netting by BU) is given, the buckets are conserved to it — the difference (unapplied payments /
credits sitting in the receivable account, not tied to an invoice) lands in 'At Date'."""
Dd = _d(D)
inv_by = defaultdict(list)
red_by = defaultdict(float)
for r in data['inv']:
if r['date'] <= D:
inv_by[r['pid']].append(r)
if not exact:
for r in data['red']:
if r['date'] <= D:
red_by[r['pid']] += r['bal']
out = {'FFS': {b: 0.0 for b in AGE}, 'RI': {b: 0.0 for b in AGE}}
overdue = {'FFS': 0.0, 'RI': 0.0}
for pid, invs in inv_by.items():
bu = invs[0]['bu']
if bu not in out:
continue
if exact:
for r in invs:
amt = r['resid']
if abs(amt) < 1e-6:
continue
days = (Dd - (_d(r['due']) or Dd)).days
out[bu][_bucket(days)] += amt
if days > 30 and amt > 0:
overdue[bu] += amt
else:
pay = -red_by.get(pid, 0.0)
pos = []
for r in invs:
if r['bal'] >= 0:
pos.append([r['due'], r['bal']])
else:
pay += -r['bal'] # credit notes act like reductions
pos.sort(key=lambda x: x[0]) # oldest due first
for it in pos:
if pay <= 0:
break
take = min(pay, it[1])
it[1] -= take
pay -= take
for due, amt in pos:
if amt < 1e-6:
continue
days = (Dd - (_d(due) or Dd)).days
out[bu][_bucket(days)] += amt
if days > 30 and amt > 0:
overdue[bu] += amt
if recv is not None:
for bu in ('FFS', 'RI'):
out[bu]['At Date'] += recv.get(bu, 0.0) - sum(out[bu].values())
return out, overdue
def _ltm_invoiced(data, D):
"""Net invoiced (out_invoice − out_refund) in the trailing 365 days ending D, by BU — for DSO."""
lo = (_d(D) - dt.timedelta(days=365)).isoformat()
out = {'FFS': 0.0, 'RI': 0.0, 'total': 0.0}
for r in data['inv']:
if lo < r['date'] <= D:
if r['bu']:
out[r['bu']] += r['bal']
out['total'] += r['bal']
return out
def _flows(data, beg, end):
"""New sales (net invoiced) + new collection (payments) in [beg, end], by BU."""
b, e = beg.isoformat(), end.isoformat()
sales = {'FFS': 0.0, 'RI': 0.0, 'total': 0.0}
coll = {'FFS': 0.0, 'RI': 0.0, 'total': 0.0}
for r in data['inv']:
if b <= r['date'] <= e and r['bu']:
sales[r['bu']] += r['bal']
sales['total'] += r['bal']
for r in data['red']:
if b <= r['date'] <= e and r['bu']:
coll[r['bu']] += -r['bal']
coll['total'] += -r['bal']
return sales, coll
def _last_payment(data):
lp = {}
for r in data['red']:
if r['bal'] < 0 and r['date']:
if r['pid'] not in lp or r['date'] > lp[r['pid']]:
lp[r['pid']] = r['date']
return lp
def _idle(data, D, lp, recv_pp):
Dd = _d(D)
out = {'FFS': {'IDLE3': 0, 'IDLE6': 0, 'IDLE12': 0, 'total': 0},
'RI': {'IDLE3': 0, 'IDLE6': 0, 'IDLE12': 0, 'total': 0}}
for pid, bal in recv_pp.items():
if bal <= 1:
continue
bu = BU_OF.get(O.m2o_id(data['parts'].get(pid, {}).get('team_id')))
if bu not in out:
continue
last = lp.get(pid)
months = ((Dd - _d(last)).days / 30.0) if last else 999
tier = 'IDLE12' if months > 12 else 'IDLE6' if months > 6 else 'IDLE3' if months > 3 else None
if tier:
out[bu][tier] += 1
out[bu]['total'] += 1
return out
def _is_cod(term_name):
return any(k in (term_name or '').lower() for k in ('immediate', 'cod', 'cash'))
def _credit_terms(data, recv_pp):
out = {'FFS': {'COD': 0, 'TOP': 0, 'total': 0}, 'RI': {'COD': 0, 'TOP': 0, 'total': 0}}
for pid, bal in recv_pp.items():
if bal <= 1:
continue
p = data['parts'].get(pid, {})
bu = BU_OF.get(O.m2o_id(p.get('team_id')))
if bu not in out:
continue
out[bu]['COD' if _is_cod(O.m2o_name(p.get('property_payment_term_id'))) else 'TOP'] += 1
out[bu]['total'] += 1
return out
# ---------------------------------------------------------------- assembly
def summary(data, t=None):
"""The biweekly time-series — a list of per-period metric dicts (latest last)."""
t = _as_of(t)
lp = _last_payment(data)
pers = periods(t)
rows = []
for i, pr in enumerate(pers):
D = pr['end'].isoformat()
recv_pp = _recv_by_partner(data, D)
recv = _agg_bu(recv_pp, data)
aging, overdue = _aging(data, D, recv, exact=(i == len(pers) - 1))
ltm = _ltm_invoiced(data, D)
sales, coll = _flows(data, pr['beg'], pr['end'])
dso = {bu: (recv[bu] / (ltm[bu] / 365.0)) if ltm.get(bu) else None for bu in ('FFS', 'RI', 'total')}
rows.append({'period': pr, 'recv': recv, 'overdue': overdue, 'aging': aging, 'ltm': ltm,
'dso': dso, 'sales': sales, 'coll': coll,
'idle': _idle(data, D, lp, recv_pp), 'terms': _credit_terms(data, recv_pp)})
for i, r in enumerate(rows): # change in receivable vs prior period end
prev = rows[i - 1]['recv'] if i else None
r['change'] = {bu: (r['recv'][bu] - prev[bu]) if prev else None for bu in ('FFS', 'RI', 'total')}
return rows
def overdue_list(data, team, t=None):
"""Current (as-of t) ranked overdue customers for a BU (5=FFS / 6=RI), exact per-invoice."""
t = _as_of(t)
Dd = t
bu = BU_OF.get(team)
lp = _last_payment(data)
by = {}
for r in data['inv']:
if r['bu'] != bu:
continue
amt = r['resid']
if abs(amt) < 1e-6:
continue
e = by.setdefault(r['pid'], dict({'pid': r['pid'], 'total': 0.0, 'overdue': 0.0}, **{b: 0.0 for b in AGE_LIST}))
e['total'] += amt
days = (Dd - (_d(r['due']) or Dd)).days
e[_lbucket(days)] += amt
if days > 30 and amt > 0:
e['overdue'] += amt
tot_od = sum(e['overdue'] for e in by.values() if e['overdue'] > 0) or 1.0
rows = []
for pid, e in by.items():
if e['overdue'] <= 0:
continue
p = data['parts'].get(pid, {})
last = lp.get(pid)
months = ((Dd - _d(last)).days / 30.0) if last else 999
idle = 'IDLE12' if months > 12 else 'IDLE6' if months > 6 else 'IDLE3' if months > 3 else 'Active'
term = O.m2o_name(p.get('property_payment_term_id'))
rows.append(dict({'customer': p.get('name') or '(unknown)', 'odoo_id': pid,
'sales_rep': O.m2o_name(p.get('user_id')) or '',
'top': 'COD' if _is_cod(term) else (term or 'TOP'),
'overdue': e['overdue'], 'overdue_pct': e['overdue'] / tot_od * 100,
'idle': idle, 'total_recv': e['total']}, **{b: e[b] for b in AGE_LIST}))
rows.sort(key=lambda x: -x['overdue'])
for i, r in enumerate(rows, 1):
r['no'] = i
return rows
def goal_status(key, value):
"""('goal', 'Achieved'|'Unmet') for a metric vs the report's hardcoded target, or (None, None)."""
g = GOALS.get(key)
if not g or value is None:
return None, None
target, better = g
ok = (value <= target) if better == 'lower' else (value >= target)
return target, ('Achieved' if ok else 'Unmet')
def validate(data, t=None):
"""Aging buckets (conserved) tie to BU receivable; the Overdue list total ties to the summary's
current overdue (the two independent code paths agree)."""
t = _as_of(t)
D = t.isoformat()
recv = _agg_bu(_recv_by_partner(data, D), data)
aging, overdue = _aging(data, D, recv, exact=True)
ag_total = sum(aging['FFS'].values()) + sum(aging['RI'].values())
checks = [{'check': 'Aging buckets (FFS+RI) == receivable (FFS+RI)',
'a': round(ag_total, 2), 'b': round(recv['FFS'] + recv['RI'], 2),
'gap': round(ag_total - (recv['FFS'] + recv['RI']), 2),
'ok': abs(ag_total - (recv['FFS'] + recv['RI'])) <= 1.0}]
od_list = sum(r['overdue'] for r in overdue_list(data, FFS, t)) + sum(r['overdue'] for r in overdue_list(data, RI, t))
od_sum = overdue['FFS'] + overdue['RI']
checks.append({'check': 'Overdue list total == summary current overdue',
'a': round(od_list, 2), 'b': round(od_sum, 2), 'gap': round(od_list - od_sum, 2),
'ok': abs(od_list - od_sum) <= 1.0})
return checks