File size: 14,376 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 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | """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 `<dim>_id` the raw value
# (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `<dim>_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'),
}
|