| """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'] |
| |
| AGE_LIST = ['At Date', '31-60', '61-90', '91-120', '>120'] |
|
|
| |
| |
| |
| SETTLE_OFFSET_DAYS = 7 |
|
|
| |
| 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')} |
|
|
|
|
| |
| 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'] |
| pos.sort(key=lambda x: x[0]) |
| 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 |
|
|
|
|
| |
| 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): |
| 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 |
|
|