"""Agent module — per-agent (res.partner.agent_ids) analytics. An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales trend, returns, top SKUs (with profit/order) and the FULL customer list — INCLUDING inactive accounts (no recent orders) so a rep sees who they've stopped selling to. Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done) so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged); everything else is BU-filterable via team_id. """ import sys import datetime as dt from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import core.odoo as O import core.periods as P import modules.sales as sales_mod import modules.customers as cust_mod def options(t=None, team_id=None): """Agent names with book activity — for the page/drawer picker.""" return cust_mod.agent_options(t, team_id) def _book(name): """frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'.""" return cust_mod.agent_partner_ids(name) def _rev(date_from, date_to, team_id, book): return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book), 'amount_untaxed') def _orders(date_from, date_to, team_id, book): return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book)) def _custs(date_from, date_to, team_id, book): return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book), 'partner_id') # ------------------------------------------------------------ period scorecard (custom dating) # Today / WTD / Last week / MTD / QTD / YTD — each carries its window so the UI can decompose it # and so "what did this agent sell this/last week" is a click, not a date-math exercise. _PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'), ('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')] def _window(key, t): if key == 'today': return P._d(t), P._d(t) if key == 'lwk': # the full prior Mon–Sun week f, _tt = P.wtd(t) start = dt.date.fromisoformat(f) - dt.timedelta(days=7) return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat() return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t) def scorecard(name, t=None, team_id=None): """Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD.""" t = t or P.today() book = _book(name) out = [] for label, key in _PERIODS: f, tt = _window(key, t) wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare cf, ct = P.shift_year(f, tt, weeks=wk) rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book) out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct, 'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': _orders(f, tt, team_id, book)}) return out def headline(name, date_from, date_to, team_id=None): """Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders, active customers, AOV and returns $ / return rate.""" book = _book(name) cf, ct = P.shift_year(date_from, date_to, weeks=False) rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book) orders = _orders(date_from, date_to, team_id, book) ret = sales_mod._returns_amt(date_from, date_to, book) return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct, 'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders, 'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0, 'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0} # ------------------------------------------------------------ sales trend def _book_monthly_rev(book, date_from, date_to, team_id=None): g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book), ['amount_untaxed:sum'], ['date_order:month'], lazy=False) out = {} for r in g: ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7] if ym: out[ym] = r.get('amount_untaxed') or 0.0 return out def monthly(name, n=13, t=None, team_id=None): """Book sales per month vs the same month last year (one month-grouped query over 2 years).""" t = t or P.today() book = _book(name) mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id) rows = [] for ym, _s, _e in P.month_starts(n, t): y, m = int(ym[:4]) - 1, int(ym[5:7]) this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0) rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)}) return rows # ------------------------------------------------------------ full customer book (incl. inactive) def customers(name, t=None, team_id=None): """EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) — those show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last).""" t = t or P.today() book = _book(name) yf, yt = P.ytd(t) lf, lt = P.ytd_last_year(t) this = cust_mod._cust_rev(yf, yt, team_id, book) last = cust_mod._cust_rev(lf, lt, team_id, book) lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency ids = list(book) if book is not None else list(set(this) | set(last)) attrs = cust_mod._partner_attrs(set(ids)) namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])} rows = [] for p in ids: tr = this.get(p, {}).get('rev', 0.0) lr = last.get(p, {}).get('rev', 0.0) a = attrs.get(p, {}) lo = lastord.get(p, '') recency = (t - dt.date.fromisoformat(lo)).days if lo else None status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive') rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'), 'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr), 'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency, 'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'), 'agent': a.get('agent', name)}) rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly']))) return rows # ------------------------------------------------------------ top SKUs (with profit/order) def top_skus(name, t=None, team_id=None, top=30): """The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order.""" t = t or P.today() book = _book(name) if book is not None and not book: return [] yf, yt = P.ytd(t) lex = [('order_partner_id', 'in', list(book))] if book is not None else None return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus'] # ------------------------------------------------------------ returns (book-scoped, consolidated) def returns_trend(name, n=13, t=None): return sales_mod.returns_monthly(n, t, partner_ids=_book(name)) def returns_headline(name, t=None): return sales_mod.returns_headline(t, partner_ids=_book(name)) # ------------------------------------------------------------ all-agents rollup (the page table) def _returns_by_agent(t=None): """{agent_name: returns$ YTD} — credit notes mapped to each customer's agent.""" by_p = sales_mod.returns_by_partner(t) attrs = cust_mod._partner_attrs(list(by_p)) agg = {} for pid, amt in by_p.items(): a = (attrs.get(pid) or {}).get('agent') or '(none)' agg[a] = agg.get(a, 0.0) + amt return agg def rollup(t=None, team_id=None): """Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return rate. Reuses the Customers MECE agent rollup, so Σ(agents) == total YTD revenue.""" rows = cust_mod.by_dimension('agent', t, team_id=team_id) ret = _returns_by_agent(t) for r in rows: r['agent'] = r['group'] r['returns'] = ret.get(r['group'], 0.0) r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0 return rows # ------------------------------------------------------------ VALIDATION def validate(t=None, team_id=None): """Reconcile the agent rollup to Odoo. (1) Σ(agent book revenue) == total YTD revenue — the rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD.""" t = t or P.today() yf, yt = P.ytd(t) checks = [] rows = rollup(t, team_id=team_id) agent_sum = sum(r['revenue'] for r in rows) total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed') checks.append({'check': 'YTD revenue: Σ(agent book) == total', 'a': round(agent_sum, 2), 'b': round(total, 2), 'gap': round(agent_sum - total, 2), 'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))}) # sampled agent: scorecard YTD == headline YTD for the same window sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None) if sample: sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0) hl = headline(sample, yf, yt, team_id)['revenue'] checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2), 'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0}) return checks # ------------------------------------------------------------ INVOICE-LINE ATTRIBUTION (2026-07-28) # A SECOND agent source. Everything above this line attributes by BOOK — the customer's assigned # agent (res.partner.agent_ids) — over confirmed SALES ORDERS. This section attributes per INVOICE # LINE, from the OCA sale-commission module, via the semantic layer (topics invoice_lines / # commission_lines). The two disagree on purpose and answer different questions: # # book -> "whose customer is this / who owns the relationship" (order basis) # invoice -> "what was actually credited to an agent on the billing" + the ONLY source that can # say what is NOT allocated to an agent (invoice basis) # # ⚠ A NAME ON A COMMISSION LINE IS NOT NECESSARILY AN AGENT — `res.partner.agent` is the flag. # "Anna" and "Shantal Erlich" are internal SALESPEOPLE who carry commission lines; the `agent` # dim excludes them and `include_salespeople` folds them back in as a clearly-labelled variant. # See [[invoice-line-agent-commission]]. _ALLOC_LABEL = {'agent': 'Allocated to an agent', 'salesperson': 'Salesperson only', 'none': 'Not allocated'} def invoice_line_rollup(t=None, team_id=None, include_salespeople=False): """Per-name invoice-line revenue + the MECE allocation split, for a YTD window. Returns {'by_agent': [...], 'allocation': [...], 'total': float, 'allocated': float, 'unallocated': float, 'basis': str} — or {'error': msg} when the tenant store is not ready (this path is store-only; there is no live fallback that stays honest about the unallocated bucket). """ import harness.semantic as S t = t or P.today() yf, yt = P.ytd(t) dim = 'commission_name' if include_salespeople else 'agent' try: by = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=[dim], date_from=yf, date_to=yt, team_id=team_id, limit=200).get('rows') or [] alloc = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=['allocation'], date_from=yf, date_to=yt, team_id=team_id, limit=10).get('rows') or [] tot = (S.store_query('invoice_lines', ['invoiced_line_sales'], date_from=yf, date_to=yt, team_id=team_id).get('rows') or [{}])[0].get('invoiced_line_sales') or 0.0 except Exception as e: # store not ready / model error — say so, don't fake return {'error': str(e)} # store_query row shape: the DIM KEY carries the display NAME and `_id` the raw value # (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `_name` # returns None for every row and silently renders the whole table as "no agent". rows = [{'agent': (r.get(dim) or '(no agent on the line)'), 'revenue': r.get('invoiced_line_sales') or 0.0} for r in by] rows.sort(key=lambda r: -r['revenue']) amap = {r.get('allocation_id') or 'none': (r.get('invoiced_line_sales') or 0.0) for r in alloc} allocated = amap.get('agent', 0.0) return { 'by_agent': rows, 'allocation': [{'bucket': _ALLOC_LABEL[k], 'revenue': amap.get(k, 0.0)} for k in ('agent', 'salesperson', 'none') if k in amap or True], 'total': tot, 'allocated': allocated, 'unallocated': tot - allocated, 'strict_none': amap.get('none', 0.0), 'salesperson_only': amap.get('salesperson', 0.0), 'window': (yf, yt), 'basis': ('invoice line · commission names incl. salespeople' if include_salespeople else 'invoice line · real agents only'), }