loopable / platform /modules /sales.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
35.7 kB
"""Sales module — revenue, YoY (same-period), seasonality, rep/customer/SKU breakdowns.
Scope: confirmed orders (state sale/done) on Fisch+Royal teams, excluded accounts removed.
Order-level metrics come from sale.order (amount_untaxed); SKU-level from
sale.order.line (price_subtotal). Each public metric has a paired validate_* that
reconciles against an independent Odoo aggregate.
"""
import sys
from functools import lru_cache
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # platform/
import core.odoo as O
import core.periods as P
import modules.inventory as inv_mod # reuse the main-category resolver
def order_domain(date_from=None, date_to=None, team_id=None, partner_ids=None):
"""Confirmed sale.order domain (RI+FFS scope, house accounts excluded). partner_ids (a
collection, possibly empty) restricts to those customers — the Customer-module Agent filter
rides on this; None = no partner restriction (an empty collection matches no orders)."""
dom = [('state', 'in', ['sale', 'done']), ('team_id', 'in', TEAMS(team_id))]
if date_from:
dom.append(('date_order', '>=', f'{date_from} 00:00:00'))
if date_to:
dom.append(('date_order', '<=', f'{date_to} 23:59:59'))
ex = O.excluded_partner_ids()
if ex:
dom.append(('partner_id', 'not in', list(ex)))
if partner_ids is not None:
dom.append(('partner_id', 'in', list(partner_ids)))
if O.doc_mode() == 'invoice':
dom.append(('invoice_status', '=', 'invoiced'))
return dom
def TEAMS(team_id):
return [team_id] if team_id is not None else O.TEAM_IDS
# ---- order-level primitives: STORE-backed (OM-2 retrofit 2026-07-12) with LIVE fallback ------
# headline/scorecard/trends make ~50 of these per build; the store serves them in milliseconds
# (kept minutes-fresh by the app's auto-sync). The SQL mirrors order_domain() EXACTLY — same
# states, teams, exclusions, date bounds and the Orders/Invoiced basis (invoice_status is
# synced). validate() stays on live Odoo = the standing parity proof. Any store problem falls
# back to the live reads.
USE_STORE = True
def _order_where_store(date_from, date_to, team_id):
"""(where_sql, params) mirroring order_domain() for the store's sale_order table."""
params, w = [], ["state IN ('sale','done')"]
teams = TEAMS(team_id)
w.append("team_id IN (" + ",".join("?" * len(teams)) + ")")
params += list(teams)
if date_from:
w.append("CAST(date_order AS TIMESTAMP) >= CAST(? AS TIMESTAMP)")
params.append(f"{date_from} 00:00:00")
if date_to:
w.append("CAST(date_order AS TIMESTAMP) <= CAST(? AS TIMESTAMP)")
params.append(f"{date_to} 23:59:59")
ex = O.excluded_partner_ids()
if ex:
w.append("partner_id NOT IN (" + ",".join("?" * len(ex)) + ")")
params += list(ex)
if O.doc_mode() == 'invoice':
w.append("invoice_status = 'invoiced'")
return " AND ".join(w), params
def _order_agg_store(expr, date_from, date_to, team_id):
import harness.datastore as DS
where, params = _order_where_store(date_from, date_to, team_id)
r = DS.ro_con().execute(
f"SELECT {expr} FROM sale_order WHERE {where}", params).fetchone()
return r[0] or 0
def _order_partner_groups_store(date_from, date_to, team_id):
"""[(partner_id, n_orders, revenue)] — feeds cadence + concentration."""
import harness.datastore as DS
where, params = _order_where_store(date_from, date_to, team_id)
return DS.ro_con().execute(
f"SELECT partner_id, count(*), sum(amount_untaxed) FROM sale_order WHERE {where} "
"GROUP BY 1", params).fetchall()
def _orev(date_from, date_to, team_id=None):
if USE_STORE:
try:
return float(_order_agg_store('sum(amount_untaxed)', date_from, date_to, team_id))
except Exception:
pass
return O.sum_field('sale.order', order_domain(date_from, date_to, team_id), 'amount_untaxed')
def _orders(date_from, date_to, team_id=None):
if USE_STORE:
try:
return int(_order_agg_store('count(*)', date_from, date_to, team_id))
except Exception:
pass
return O.get_odoo().search_count('sale.order', order_domain(date_from, date_to, team_id))
def _custs(date_from, date_to, team_id=None):
if USE_STORE:
try:
return int(_order_agg_store('count(DISTINCT partner_id)',
date_from, date_to, team_id))
except Exception:
pass
return O.distinct_count('sale.order', order_domain(date_from, date_to, team_id), 'partner_id')
# ---------------------------------------------------------------- headline
def headline(t=None, team_id=None):
"""Headline scoped to a DBA (team_id) or consolidated (None). by_team always shows both."""
t = t or P.today()
yf, yt = P.ytd(t)
lf, lt = P.ytd_last_year(t)
rev = _orev(yf, yt, team_id)
rev_ly = _orev(lf, lt, team_id)
orders = _orders(yf, yt, team_id)
custs = _custs(yf, yt, team_id)
out = {
'as_of': yt,
'ytd_revenue': rev,
'ytd_revenue_ly': rev_ly,
'yoy_pct': P.yoy_pct(rev, rev_ly),
'ytd_orders': orders,
'ytd_customers': custs,
'aov': (rev / orders) if orders else 0.0,
'by_team': {},
}
for tid in O.TEAM_IDS:
out['by_team'][O.TEAM_NAMES[tid]] = {
'ytd': _orev(yf, yt, tid),
'ytd_ly': _orev(lf, lt, tid),
}
return out
# ---------------------------------------------------------------- period scorecard
# (label, key, weekday-aligned-LY?) — Today/WTD compare to 52 weeks ago (same weekday); the
# month/quarter/year periods compare to the same calendar window last year.
_PERIODS = [('Today', 'today', True), ('Week to date', 'wtd', True), ('Month to date', 'mtd', False),
('Quarter to date', 'qtd', False), ('Year to date', 'ytd', False)]
def _period_window(key, t):
import datetime as dt
if key == 'today':
return P._d(t), P._d(t)
return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
def period_scorecard(t=None, team_id=None):
"""The headline period scorecard: revenue (+ YoY same-period), orders, AOV and active customers
for Today / WTD / MTD / QTD / YTD. Each entry carries its date window so the UI can make every
number click through to the decomposition drawer."""
t = t or P.today()
out = []
for label, key, wk in _PERIODS:
f, tt = _period_window(key, t)
cf, ct = P.shift_year(f, tt, weeks=wk)
rev = _orev(f, tt, team_id)
rev_ly = _orev(cf, ct, team_id)
orders = _orders(f, tt, team_id)
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, 'customers': _custs(f, tt, team_id),
'aov': (rev / orders) if orders else 0.0,
})
return out
# ---------------------------------------------------------------- seasonality / trend
def weekly_trend(n_weeks=13, t=None, team_id=None):
"""Per-week revenue for the last n_weeks (Mon–Sun) vs the same week 52 weeks earlier (weekday-
aligned YoY). Each row carries start/end so a clicked week decomposes to its exact window."""
t = t or P.today()
rows = []
for label, start, end in P.week_starts(n_weeks, t):
this = _orev(start, end, team_id)
cf, ct = P.shift_year(start, end, weeks=True)
last = _orev(cf, ct, team_id)
rows.append({'week': label, 'start': start, 'end': end, 'cmp_from': cf, 'cmp_to': ct,
'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
return rows
def monthly_trend(n=13, t=None, team_id=None):
"""Per-month revenue for the last n months + same month one year earlier (YoY)."""
t = t or P.today()
rows = []
for ym, start, end in P.month_starts(n, t):
this = _orev(start, end, team_id)
# same month last year
y, m = int(ym[:4]) - 1, int(ym[5:7])
import datetime as dt
ly_start = dt.date(y, m, 1).isoformat()
ly_end = (dt.date(y + (m // 12), (m % 12) + 1, 1) - dt.timedelta(days=1)).isoformat()
last = _orev(ly_start, ly_end, team_id)
rows.append({'month': ym, 'start': start, 'end': end, 'cmp_from': ly_start, 'cmp_to': ly_end,
'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
return rows
# ---------------------------------------------------------------- breakdowns
def by_team(t=None):
t = t or P.today()
yf, yt = P.ytd(t)
lf, lt = P.ytd_last_year(t)
return [{'team': O.TEAM_NAMES[tid], 'ytd': _orev(yf, yt, tid),
'ytd_ly': _orev(lf, lt, tid),
'yoy_pct': P.yoy_pct(_orev(yf, yt, tid), _orev(lf, lt, tid))}
for tid in O.TEAM_IDS]
def by_rep(t=None, limit=25, team_id=None):
t = t or P.today()
yf, yt = P.ytd(t)
g = O.read_group('sale.order', order_domain(yf, yt, team_id),
['amount_untaxed:sum'], ['user_id'], lazy=False)
rows = [{'rep': O.m2o_name(r.get('user_id')) or '(none)', 'uid': O.m2o_id(r.get('user_id')),
'revenue': r.get('amount_untaxed') or 0.0,
'orders': r.get('__count') or r.get('user_id_count') or 0}
for r in g]
rows.sort(key=lambda x: -x['revenue'])
return rows[:limit]
def category_product_ids(category):
"""The product ids belonging to a main category — for decomposing a category number."""
return [pid for pid, c in _product_cat().items() if c == category]
def _top_customers_store(yf, yt, team_id, limit):
import harness.datastore as DS
params, w = [], ["state IN ('sale','done')"]
teams = TEAMS(team_id)
w.append("team_id IN (" + ",".join("?" * len(teams)) + ")")
params += list(teams)
w.append("CAST(date_order AS TIMESTAMP) >= CAST(? AS TIMESTAMP)")
params.append(f"{yf} 00:00:00")
w.append("CAST(date_order AS TIMESTAMP) <= CAST(? AS TIMESTAMP)")
params.append(f"{yt} 23:59:59")
ex = O.excluded_partner_ids()
if ex:
w.append("partner_id NOT IN (" + ",".join("?" * len(ex)) + ")")
params += list(ex)
if O.doc_mode() == 'invoice':
w.append("invoice_status = 'invoiced'")
rows = DS.ro_con().execute(
"SELECT o.partner_id, coalesce(p.name, '#' || o.partner_id), "
"sum(o.amount_untaxed), count(*) "
"FROM sale_order o LEFT JOIN res_partner p ON p.id = o.partner_id "
"WHERE " + " AND ".join(w) + " GROUP BY 1, 2 ORDER BY 3 DESC LIMIT ?",
params + [int(limit)]).fetchall()
return [{'pid': r[0], 'customer': r[1], 'revenue': r[2] or 0.0, 'orders': r[3]}
for r in rows if r[0]]
def top_customers(t=None, limit=25, team_id=None):
t = t or P.today()
yf, yt = P.ytd(t)
if USE_STORE:
try:
return _top_customers_store(yf, yt, team_id, limit)
except Exception:
pass
g = O.read_group('sale.order', order_domain(yf, yt, team_id),
['amount_untaxed:sum'], ['partner_id'], lazy=False)
rows = [{'pid': O.m2o_id(r.get('partner_id')),
'customer': O.m2o_name(r.get('partner_id')),
'revenue': r.get('amount_untaxed') or 0.0,
'orders': r.get('__count') or 0}
for r in g if r.get('partner_id')]
rows.sort(key=lambda x: -x['revenue'])
return rows[:limit]
def _top_skus_store(yf, yt, team_id, limit):
"""Mirrors sale_line_domain: confirmed states, order-side teams/dates/invoice-mode,
line-side partner exclusion, product_id set. Names + SKU codes join in-store (the live
path needs an extra search_read for codes)."""
import harness.datastore as DS
params, w = [], ["o.state IN ('sale','done')", "l.product_id IS NOT NULL"]
teams = TEAMS(team_id)
w.append("o.team_id IN (" + ",".join("?" * len(teams)) + ")")
params += list(teams)
w.append("CAST(o.date_order AS TIMESTAMP) >= CAST(? AS TIMESTAMP)")
params.append(f"{yf} 00:00:00")
w.append("CAST(o.date_order AS TIMESTAMP) <= CAST(? AS TIMESTAMP)")
params.append(f"{yt} 23:59:59")
ex = O.excluded_partner_ids()
if ex:
w.append("l.order_partner_id NOT IN (" + ",".join("?" * len(ex)) + ")")
params += list(ex)
if O.doc_mode() == 'invoice':
w.append("o.invoice_status = 'invoiced'")
rows = DS.ro_con().execute(
"SELECT l.product_id, coalesce(p.name, '#' || l.product_id), p.default_code, "
"sum(l.price_subtotal), sum(l.product_uom_qty), sum(l.margin), count(*) "
"FROM sale_order_line l JOIN sale_order o ON o.id = l.order_id "
"LEFT JOIN product_product p ON p.id = l.product_id "
"WHERE " + " AND ".join(w) + " GROUP BY 1, 2, 3 ORDER BY 4 DESC LIMIT ?",
params + [int(limit)]).fetchall()
return [_sku_profit({'product': r[1], 'pid': r[0],
'code': (str(r[2]).strip() if r[2] else None),
'revenue': r[3] or 0.0, 'qty': r[4] or 0.0,
'margin': r[5] or 0.0, 'lines': r[6]})
for r in rows if r[0]]
def top_skus(t=None, limit=25, team_id=None):
t = t or P.today()
yf, yt = P.ytd(t)
if USE_STORE:
try:
return _top_skus_store(yf, yt, team_id, limit)
except Exception:
pass
g = O.read_group('sale.order.line', O.sale_line_domain(yf, yt, team_id),
['price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'], ['product_id'], lazy=False)
rows = [_sku_profit({'product': O.m2o_name(r.get('product_id')), 'pid': O.m2o_id(r.get('product_id')),
'revenue': r.get('price_subtotal') or 0.0,
'qty': r.get('product_uom_qty') or 0.0,
'margin': r.get('margin') or 0.0, 'lines': r.get('__count') or 0})
for r in g if r.get('product_id')]
rows.sort(key=lambda x: -x['revenue'])
rows = rows[:limit]
if rows: # attach SKU code so the UI can deep-link each to its SKU drawer
codemap = {}
for pr in O.search_read('product.product', [('id', 'in', [r['pid'] for r in rows])], ['default_code']):
codemap[pr['id']] = str(pr['default_code']).strip() if pr.get('default_code') else None
for r in rows:
r['code'] = codemap.get(r['pid'])
return rows
def _sku_profit(s):
"""Attach profit/order + margin% to a SKU row (uses the Margin module's `margin` sum). `lines`
(order lines) ~= the number of orders containing the SKU (one line per SKU per order in
practice), so profit/order = total margin ÷ lines."""
lines = s.get('lines') or 0
s['profit_per_order'] = (s['margin'] / lines) if lines else 0.0
s['margin_pct'] = (s['margin'] / s['revenue'] * 100.0) if s.get('revenue') else 0.0
return s
def _attach_sku_codes(rows, pid_key='pid'):
"""Attach SKU `code` (default_code) to product rows so each can deep-link to its SKU drawer."""
ids = [r[pid_key] for r in rows if r.get(pid_key)]
if not ids:
return rows
codemap = {}
for pr in O.search_read('product.product', [('id', 'in', ids)], ['default_code']):
codemap[pr['id']] = str(pr['default_code']).strip() if pr.get('default_code') else None
for r in rows:
r['code'] = codemap.get(r.get(pid_key))
return rows
def decompose(date_from, date_to, team_id=None, line_extra=None, order_extra=None,
product_ids=None, compare=None, top=30):
"""Universal decomposition of ANY sales number into its contributors over a window. Line-level
(sale.order.line) so it breaks down by customer, SKU and category consistently and ties to the
headline (line == order revenue, proven in validate()).
line_extra extra sale.order.line domain clauses (e.g. a rep via order_id.user_id)
order_extra the order-level translation of the same scope (for the orders count)
product_ids restrict to a category's products
compare (cmp_from, cmp_to) for the same-period-last-year total (YoY headline)
Returns totals + ranked `customers`, `skus`, `categories` (each with revenue + % share)."""
ex = list(line_extra or [])
if product_ids is not None:
ex.append(('product_id', 'in', list(product_ids)))
dom = O.sale_line_domain(date_from, date_to, team_id, extra=ex)
gc = O.read_group('sale.order.line', dom, ['price_subtotal:sum', 'product_uom_qty:sum'],
['order_partner_id'], lazy=False)
customers = [{'pid': O.m2o_id(r.get('order_partner_id')), 'customer': O.m2o_name(r.get('order_partner_id')),
'revenue': r.get('price_subtotal') or 0.0, 'qty': r.get('product_uom_qty') or 0.0,
'lines': r.get('__count') or 0} for r in gc if r.get('order_partner_id')]
gs = O.read_group('sale.order.line', dom, ['price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'],
['product_id'], lazy=False)
skus = [_sku_profit({'pid': O.m2o_id(r.get('product_id')), 'product': O.m2o_name(r.get('product_id')),
'revenue': r.get('price_subtotal') or 0.0, 'qty': r.get('product_uom_qty') or 0.0,
'margin': r.get('margin') or 0.0, 'lines': r.get('__count') or 0})
for r in gs if r.get('product_id')]
cat = _product_cat()
cagg = {}
for s in skus:
c = cat.get(s['pid'], '(uncategorized)')
e = cagg.setdefault(c, {'category': c, 'revenue': 0.0, 'qty': 0.0, 'skus': 0})
e['revenue'] += s['revenue']; e['qty'] += s['qty']; e['skus'] += 1
categories = sorted(cagg.values(), key=lambda x: -x['revenue'])
total = sum(c['revenue'] for c in customers)
units = sum(s['qty'] for s in skus)
for lst in (customers, skus, categories):
for r in lst:
r['pct'] = (r['revenue'] / total * 100.0) if total else 0.0
customers.sort(key=lambda x: -x['revenue'])
skus.sort(key=lambda x: -x['revenue'])
_attach_sku_codes(skus)
# orders count (order-level, single cheap aggregate) + AOV
odom = order_domain(date_from, date_to, team_id) + list(order_extra or [])
if product_ids is not None and not order_extra:
odom = odom + [('order_line.product_id', 'in', list(product_ids))]
orders = O.get_odoo().search_count('sale.order', odom)
total_ly = None
if compare:
lex = list(line_extra or [])
if product_ids is not None:
lex.append(('product_id', 'in', list(product_ids)))
total_ly = O.sum_field('sale.order.line',
O.sale_line_domain(compare[0], compare[1], team_id, extra=lex), 'price_subtotal')
return {
'window': f'{date_from}{date_to}', 'date_from': date_from, 'date_to': date_to,
'total': total, 'total_ly': total_ly, 'yoy_pct': (P.yoy_pct(total, total_ly) if total_ly is not None else None),
'orders': orders, 'units': units, 'n_customers': len(customers), 'n_skus': len(skus),
'aov': (total / orders) if orders else 0.0,
'customers': customers[:top], 'skus': skus[:top], 'categories': categories,
'all_customers': customers, 'all_skus': skus,
}
_ORDER_STATE = {'draft': 'Quote', 'sent': 'Quote sent', 'sale': 'Confirmed', 'done': 'Locked',
'cancel': 'Cancelled'}
_ORDER_INV = {'upselling': 'Upselling', 'invoiced': 'Invoiced', 'to invoice': 'To invoice',
'no': 'Nothing to invoice'}
def orders_in_scope(date_from, date_to, team_id=None, line_extra=None, order_extra=None,
product_ids=None):
"""The per-ORDER list behind any sales number over a window — the raw sale.order rows that make
up a decomposition, so a chart click drills all the way down to the individual orders (each
exportable to Excel). Revenue / units / margin are the IN-SCOPE line contribution (e.g. for a
clicked SKU, only that SKU's lines), so Σ(order revenue) ties to the decomposition headline.
Returns ALL orders (no silent cap — the export must be complete), newest-revenue first."""
ex = list(line_extra or [])
if product_ids is not None:
ex.append(('product_id', 'in', list(product_ids)))
dom = O.sale_line_domain(date_from, date_to, team_id, extra=ex)
g = O.read_group('sale.order.line', dom,
['price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'],
['order_id'], lazy=False)
by_id = {}
for r in g:
oid = O.m2o_id(r.get('order_id'))
if not oid:
continue
by_id[oid] = {'oid': oid, 'order': O.m2o_name(r.get('order_id')),
'revenue': r.get('price_subtotal') or 0.0,
'qty': r.get('product_uom_qty') or 0.0,
'margin': r.get('margin') or 0.0, 'lines': r.get('__count') or 0}
if not by_id:
return []
# one order-level pull for the human columns (date / customer / status)
for m in O.search_read('sale.order', [('id', 'in', list(by_id))],
['name', 'date_order', 'partner_id', 'state', 'invoice_status']):
e = by_id.get(m['id'])
if not e:
continue
e['order'] = m.get('name') or e['order']
e['date'] = (m.get('date_order') or '')[:10]
e['customer'] = O.m2o_name(m.get('partner_id'))
e['status'] = _ORDER_STATE.get(m.get('state'), m.get('state') or '')
e['invoiced'] = _ORDER_INV.get(m.get('invoice_status'), m.get('invoice_status') or '')
return sorted(by_id.values(), key=lambda x: -x['revenue'])
@lru_cache(maxsize=1)
def _product_cat():
"""product_id → main category name (reuses inventory's category resolver).
Cached for the process — the category taxonomy is structural and rarely changes; this avoids
re-reading the whole product master on every customer drill-down / win-back."""
catmap = inv_mod._cat_main_map()
prods = O.search_read('product.product', [('default_code', '!=', False)], ['id', 'categ_id'])
return {p['id']: (catmap.get(O.m2o_id(p.get('categ_id'))) or '(uncategorized)') for p in prods}
def _line_rev_by_product_store(date_from, date_to, team_id):
"""[(product_id, Σ price_subtotal)] mirroring sale_line_domain (order-side scope join,
line-side exclusions, product set)."""
import harness.datastore as DS
params, w = [], ["o.state IN ('sale','done')", "l.product_id IS NOT NULL"]
teams = TEAMS(team_id)
w.append("o.team_id IN (" + ",".join("?" * len(teams)) + ")")
params += list(teams)
w.append("CAST(o.date_order AS TIMESTAMP) >= CAST(? AS TIMESTAMP)")
params.append(f"{date_from} 00:00:00")
w.append("CAST(o.date_order AS TIMESTAMP) <= CAST(? AS TIMESTAMP)")
params.append(f"{date_to} 23:59:59")
ex = O.excluded_partner_ids()
if ex:
w.append("l.order_partner_id NOT IN (" + ",".join("?" * len(ex)) + ")")
params += list(ex)
if O.doc_mode() == 'invoice':
w.append("o.invoice_status = 'invoiced'")
return DS.ro_con().execute(
"SELECT l.product_id, sum(l.price_subtotal) FROM sale_order_line l "
"JOIN sale_order o ON o.id = l.order_id WHERE " + " AND ".join(w) + " GROUP BY 1",
params).fetchall()
def _cat_rev(date_from, date_to, cat, team_id=None):
"""Revenue per main category over a window (line-level, brand-aware)."""
if USE_STORE:
try:
out = {}
for pid, amt in _line_rev_by_product_store(date_from, date_to, team_id):
c = cat.get(pid, '(uncategorized)')
out[c] = out.get(c, 0.0) + (amt or 0.0)
return out
except Exception:
pass
g = O.read_group('sale.order.line', O.sale_line_domain(date_from, date_to, team_id),
['price_subtotal:sum'], ['product_id'], lazy=False)
out = {}
for r in g:
pid = O.m2o_id(r.get('product_id'))
if not pid:
continue
c = cat.get(pid, '(uncategorized)')
out[c] = out.get(c, 0.0) + (r.get('price_subtotal') or 0.0)
return out
def by_category(t=None, limit=20, team_id=None):
"""Revenue by main category, YTD vs same-period last year — which categories drive (or
drag) the number. Brand-filterable, so you can see e.g. which categories Fisch is losing."""
t = t or P.today()
yf, yt = P.ytd(t)
lf, lt = P.ytd_last_year(t)
cat = _product_cat()
this = _cat_rev(yf, yt, cat, team_id)
last = _cat_rev(lf, lt, cat, team_id)
rows = [{'category': c, 'revenue': this.get(c, 0.0), 'revenue_ly': last.get(c, 0.0),
'change': this.get(c, 0.0) - last.get(c, 0.0),
'yoy_pct': P.yoy_pct(this.get(c, 0.0), last.get(c, 0.0))}
for c in (set(this) | set(last))]
rows.sort(key=lambda x: -x['revenue'])
return rows[:limit]
def cadence(t=None, team_id=None):
"""Reorder behaviour over LTM: repeat-purchase rate, avg orders/customer, and the
order-frequency distribution. A wholesale-health signal (are customers coming back?)."""
t = t or P.today()
lf, lt = P.ltm(t)
counts = None
if USE_STORE:
try:
counts = [r[1] for r in _order_partner_groups_store(lf, lt, team_id) if r[0]]
except Exception:
counts = None
if counts is None:
g = O.read_group('sale.order', order_domain(lf, lt, team_id),
['partner_id'], ['partner_id'], lazy=False)
counts = [r.get('__count') or 0 for r in g if r.get('partner_id')]
n = len(counts)
total_orders = sum(counts)
repeat = sum(1 for c in counts if c >= 2)
buckets = [('1 order', lambda c: c == 1), ('2-3 orders', lambda c: 2 <= c <= 3),
('4-9 orders', lambda c: 4 <= c <= 9), ('10+ orders', lambda c: c >= 10)]
dist = [{'frequency': label, 'customers': sum(1 for c in counts if fn(c)),
'pct': (sum(1 for c in counts if fn(c)) / n * 100) if n else 0.0}
for label, fn in buckets]
return {
'customers': n,
'total_orders': total_orders,
'avg_orders': (total_orders / n) if n else 0.0,
'repeat_customers': repeat,
'repeat_rate': (repeat / n * 100) if n else 0.0,
'distribution': dist,
'ltm_window': f'{lf}{lt}',
}
def concentration(t=None, team_id=None):
"""Top-N customer share of YTD revenue."""
t = t or P.today()
yf, yt = P.ytd(t)
revs = None
if USE_STORE:
try:
revs = sorted([r[2] or 0.0 for r in _order_partner_groups_store(yf, yt, team_id)
if r[0]], reverse=True)
except Exception:
revs = None
if revs is None:
g = O.read_group('sale.order', order_domain(yf, yt, team_id),
['amount_untaxed:sum'], ['partner_id'], lazy=False)
revs = sorted([r.get('amount_untaxed') or 0.0 for r in g if r.get('partner_id')],
reverse=True)
total = sum(revs) or 1.0
def share(n):
return sum(revs[:n]) / total * 100.0
return {'n_customers': len(revs), 'total': total,
'top10_pct': share(10), 'top25_pct': share(25),
'top50_pct': share(50), 'top100_pct': share(100)}
# ---------------------------------------------------------------- returns (credit notes)
# Returns = posted customer credit notes (account.move, move_type='out_refund'). They carry a
# partner and salesperson but sit on the generic 'Sales' team (not Fisch/Royal team 5/6), so
# returns are reported CONSOLIDATED and sliced by agent (via the customer's res.partner.agent_ids)
# and by period — never BU-split. amount_untaxed_signed is negative for out_refund; we flip it so
# a "return" reads as a positive dollar figure everywhere.
def returns_domain(date_from=None, date_to=None, partner_ids=None):
dom = [('move_type', '=', 'out_refund'), ('state', '=', 'posted')]
if date_from:
dom.append(('invoice_date', '>=', date_from))
if date_to:
dom.append(('invoice_date', '<=', date_to))
ex = O.excluded_partner_ids()
if ex:
dom.append(('partner_id', 'not in', list(ex)))
if partner_ids is not None:
dom.append(('partner_id', 'in', list(partner_ids)))
return dom
def _returns_amt(date_from, date_to, partner_ids=None):
"""Total returns $ (positive) over a window."""
v = O.sum_field('account.move', returns_domain(date_from, date_to, partner_ids), 'amount_untaxed_signed')
return -(v or 0.0)
def returns_monthly(n=13, t=None, partner_ids=None):
"""Returns $ per month for the last n months + same month one year earlier (YoY). Shaped like
the revenue trend (`revenue`/`revenue_ly` keys) so it renders through the same chart_yoy_bars.
ONE month-grouped read over a ~2-year span (was 2n sequential sum queries)."""
t = t or P.today()
months = P.month_starts(n, t)
span_from = f"{int(months[0][0][:4]) - 1:04d}-{months[0][0][5:7]}-01" # 1 year before the first month
g = O.read_group('account.move', returns_domain(span_from, t.isoformat(), partner_ids),
['amount_untaxed_signed:sum'], ['invoice_date:month'], lazy=False)
mret = {}
for r in g:
ym = ((r.get('__range') or {}).get('invoice_date:month') or {}).get('from', '')[:7]
if ym:
mret[ym] = -(r.get('amount_untaxed_signed') or 0.0)
rows = []
for ym, start, end in months:
y, m = int(ym[:4]) - 1, int(ym[5:7])
this, last = mret.get(ym, 0.0), mret.get(f'{y:04d}-{m:02d}', 0.0)
rows.append({'month': ym, 'start': start, 'end': end,
'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
return rows
def returns_by_partner(t=None, partner_ids=None):
"""{partner_id: returns$ YTD} (positive) — the raw per-customer credit-note total. The agent
rollup maps these to res.partner.agent_ids in the Customers module."""
t = t or P.today()
yf, yt = P.ytd(t)
g = O.read_group('account.move', returns_domain(yf, yt, partner_ids),
['amount_untaxed_signed:sum'], ['partner_id'], lazy=False)
return {O.m2o_id(r.get('partner_id')): -(r.get('amount_untaxed_signed') or 0.0)
for r in g if r.get('partner_id')}
def returns_headline(t=None, partner_ids=None):
"""YTD returns $ + YoY + return rate (returns / gross revenue). Consolidated."""
t = t or P.today()
yf, yt = P.ytd(t)
lf, lt = P.ytd_last_year(t)
ret = _returns_amt(yf, yt, partner_ids)
ret_ly = _returns_amt(lf, lt, partner_ids)
gross = _orev(yf, yt, None) if partner_ids is None else O.sum_field(
'sale.order', order_domain(yf, yt, None, partner_ids=partner_ids), 'amount_untaxed')
return {'ytd': ret, 'ytd_ly': ret_ly, 'yoy_pct': P.yoy_pct(ret, ret_ly),
'rate_pct': (ret / gross * 100.0) if gross else 0.0}
# ---------------------------------------------------------------- VALIDATION
def validate(t=None, team_id=None):
"""Reconcile metrics against independent Odoo aggregates. Returns list of checks.
When team_id is set (a single BU selected) every check runs SCOPED to that BU, so the
validation panel never reconciles against — or exposes — the other BU's numbers. The one
cross-BU check (#2, Σ teams == total) only makes sense consolidated, so it runs only when
team_id is None."""
t = t or P.today()
yf, yt = P.ytd(t)
checks = []
# 1. Order-level revenue (sale.order) vs line-level revenue (sale.order.line)
order_rev = _orev(yf, yt, team_id)
line_rev = O.sum_field('sale.order.line', O.sale_line_domain(yf, yt, team_id), 'price_subtotal')
gap = order_rev - line_rev
checks.append({
'check': 'YTD revenue: order-level == line-level',
'a': round(order_rev, 2), 'b': round(line_rev, 2),
'gap': round(gap, 2),
'ok': abs(gap) <= max(1.0, 0.001 * order_rev)})
# 2. Sum of per-team revenue == total (consolidated only — cross-BU)
if team_id is None:
team_sum = sum(_orev(yf, yt, tid) for tid in O.TEAM_IDS)
checks.append({
'check': 'YTD revenue: Σ(team) == total',
'a': round(team_sum, 2), 'b': round(order_rev, 2),
'gap': round(team_sum - order_rev, 2),
'ok': abs(team_sum - order_rev) <= 1.0})
# 3. Sum of per-customer revenue == total
cust_sum = sum(c['revenue'] for c in top_customers(t, limit=10**9, team_id=team_id))
checks.append({
'check': 'YTD revenue: Σ(customer) == total',
'a': round(cust_sum, 2), 'b': round(order_rev, 2),
'gap': round(cust_sum - order_rev, 2),
'ok': abs(cust_sum - order_rev) <= 1.0})
# 4. Σ(category revenue) == line-level total (YTD) — SAME-SOURCE on purpose (wave-14 debt
# sweep). `by_category` reads the DATASTORE mirror when USE_STORE while `line_rev` above is
# LIVE Odoo, so this check used to compare two sources and went red on a pending sync
# (a stable +2,499.90 measured twice 75s apart, 2026-08-02 — the same mixed-source
# signature as the wave-13 ±$8.5k triple, which healed on resync). The decomposition's
# claim — every line lands in exactly one category, none lost, none doubled — must be
# judged against the SAME rows the decomposition consumed; order-vs-line freshness is
# check 1's job, on one source.
cat_sum = sum(c['revenue'] for c in by_category(t, limit=10**9, team_id=team_id))
cat_line_total = line_rev
if USE_STORE:
try:
cat_line_total = sum((amt or 0.0) for _pid, amt in
_line_rev_by_product_store(yf, yt, team_id))
except Exception:
pass
checks.append({
'check': 'YTD revenue: Σ(category) == line-level total',
'a': round(cat_sum, 2), 'b': round(cat_line_total, 2),
'gap': round(cat_sum - cat_line_total, 2),
'ok': abs(cat_sum - cat_line_total) <= 1.0})
# 6. Cadence: Σ(frequency-bucket customers) == total LTM customers
cad = cadence(t, team_id=team_id)
bucket_n = sum(d['customers'] for d in cad['distribution'])
checks.append({
'check': 'Cadence: Σ(frequency buckets) == LTM customers',
'a': bucket_n, 'b': cad['customers'],
'gap': bucket_n - cad['customers'],
'ok': bucket_n == cad['customers']})
# 7. Decompose ties out: the YTD decomposition total == line-level YTD, and its customer / SKU /
# category breakdowns each sum back to that total (the drill-any-number drawer is trustworthy).
dec = decompose(yf, yt, team_id=team_id)
checks.append({
'check': 'Decompose YTD total == line-level revenue',
'a': round(dec['total'], 2), 'b': round(line_rev, 2),
'gap': round(dec['total'] - line_rev, 2),
'ok': abs(dec['total'] - line_rev) <= max(1.0, 0.001 * line_rev)})
cust_d = sum(c['revenue'] for c in dec['all_customers'])
sku_d = sum(s['revenue'] for s in dec['all_skus'])
cat_d = sum(c['revenue'] for c in dec['categories'])
checks.append({
'check': 'Decompose: Σ(customers)=Σ(SKUs)=Σ(categories)=total',
'a': round(cust_d, 2), 'b': round(dec['total'], 2),
'gap': round(max(abs(cust_d - dec['total']), abs(sku_d - dec['total']), abs(cat_d - dec['total'])), 2),
'ok': all(abs(x - dec['total']) <= 1.0 for x in (cust_d, sku_d, cat_d))})
return checks