| """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])) |
| import core.odoo as O |
| import core.periods as P |
| import modules.inventory as inv_mod |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| 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') |
|
|
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| _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 |
|
|
|
|
| |
| 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) |
| |
| 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 |
|
|
|
|
| |
| 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: |
| 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) |
|
|
| |
| 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 [] |
| |
| 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)} |
|
|
|
|
| |
| |
| |
| |
| |
| |
| 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" |
| 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} |
|
|
|
|
| |
| 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 = [] |
|
|
| |
| 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)}) |
|
|
| |
| 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}) |
|
|
| |
| 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}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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}) |
|
|
| |
| 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']}) |
|
|
| |
| |
| 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 |
|
|