| """Assortment module — facet-level performance from the curated product taxonomy the team |
| already maintains IN Odoo (x_main/x_sub/x_color/x_material/x_occasion/x_collection + |
| x_studio_season), which no other module used until 2026-07-05. |
| |
| Governing question (dashboard standard): WHICH PARTS OF THE ASSORTMENT EARN THEIR KEEP — AND IS |
| THE SEASONAL BUY READY? Revenue/margin/YoY per facet value (BU-scoped via the standard wholesale |
| line domain), plus season readiness: units the coming 6 months sold LAST year per season tag vs |
| units on hand today. validate() reconciles facet partitions to independent totals. |
| """ |
| 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 |
|
|
| FACETS = [('x_main', 'Main category'), ('x_sub', 'Sub-category'), ('x_color', 'Color'), |
| ('x_material', 'Material'), ('x_occasion', 'Occasion'), |
| ('x_collection', 'Collection'), ('x_studio_season', 'Season')] |
| UNTAGGED = '(untagged)' |
|
|
|
|
| def _products(): |
| """pid -> {code, facets..., on_hand, cost}. One pull, all facet fields.""" |
| fields = ['default_code', 'qty_available', 'standard_price'] + [f for f, _ in FACETS] |
| out = {} |
| for p in O.search_read('product.product', |
| [('default_code', '!=', False), ('active', 'in', [True, False])], |
| fields): |
| out[p['id']] = p |
| return out |
|
|
|
|
| def _rev_by_product(df, dt_, team_id=None): |
| """product_id -> {rev, qty, margin} over the window, standard wholesale scope.""" |
| out = {} |
| for g in O.read_group('sale.order.line', O.sale_line_domain(df, dt_, team_id), |
| ['price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'], |
| ['product_id'], lazy=False): |
| if g.get('product_id'): |
| out[O.m2o_id(g['product_id'])] = { |
| 'rev': g.get('price_subtotal') or 0.0, |
| 'qty': g.get('product_uom_qty') or 0.0, |
| 'margin': g.get('margin') or 0.0} |
| return out |
|
|
|
|
| def build(team_id=None, t=None): |
| t = t or P.today() |
| yf, yt = P.ytd(t) |
| lf, lt = P.ytd_last_year(t) |
| prods = _products() |
| now = _rev_by_product(yf, yt, team_id) |
| ly = _rev_by_product(lf, lt, team_id) |
|
|
| |
| dims = {} |
| for fkey, flabel in FACETS: |
| agg = {} |
| for pid, r in now.items(): |
| p = prods.get(pid) |
| val = ((p or {}).get(fkey) or UNTAGGED) if p else UNTAGGED |
| val = str(val).strip() or UNTAGGED |
| e = agg.setdefault(val, {'value': val, 'rev': 0.0, 'rev_ly': 0.0, 'margin': 0.0, |
| 'qty': 0.0, 'skus': set()}) |
| e['rev'] += r['rev'] |
| e['margin'] += r['margin'] |
| e['qty'] += r['qty'] |
| e['skus'].add(pid) |
| for pid, r in ly.items(): |
| p = prods.get(pid) |
| val = ((p or {}).get(fkey) or UNTAGGED) if p else UNTAGGED |
| val = str(val).strip() or UNTAGGED |
| agg.setdefault(val, {'value': val, 'rev': 0.0, 'rev_ly': 0.0, 'margin': 0.0, |
| 'qty': 0.0, 'skus': set()})['rev_ly'] += r['rev'] |
| rows = [] |
| for e in agg.values(): |
| e['n_skus'] = len(e['skus']) |
| del e['skus'] |
| e['gm_pct'] = (e['margin'] / e['rev'] * 100) if e['rev'] else None |
| e['yoy_pct'] = ((e['rev'] - e['rev_ly']) / e['rev_ly'] * 100) if e['rev_ly'] else None |
| e['yoy_abs'] = e['rev'] - e['rev_ly'] |
| rows.append(e) |
| rows.sort(key=lambda x: -x['rev']) |
| dims[fkey] = {'label': flabel, 'rows': rows} |
|
|
| |
| total_rev = sum(r['rev'] for r in now.values()) |
| faceted_rev = sum(r['rev'] for pid, r in now.items() |
| if prods.get(pid, {}).get('x_main')) |
| n_faceted = sum(1 for p in prods.values() if p.get('x_main')) |
|
|
| |
| nf, ntt = (t - dt.timedelta(days=365)).isoformat(), (t + dt.timedelta(days=180) - dt.timedelta(days=365)).isoformat() |
| ahead_ly = _rev_by_product(nf, ntt, team_id) |
| seasons = {} |
| for pid, r in ahead_ly.items(): |
| p = prods.get(pid) |
| s = str((p or {}).get('x_studio_season') or '').strip() |
| if not s: |
| continue |
| e = seasons.setdefault(s, {'season': s, 'demand_units_ly': 0.0, 'demand_rev_ly': 0.0, |
| 'on_hand_units': 0.0, 'on_hand_value': 0.0, 'skus': set()}) |
| e['demand_units_ly'] += r['qty'] |
| e['demand_rev_ly'] += r['rev'] |
| e['skus'].add(pid) |
| for pid, p in prods.items(): |
| s = str(p.get('x_studio_season') or '').strip() |
| if s and s in seasons: |
| seasons[s]['on_hand_units'] += p.get('qty_available') or 0.0 |
| seasons[s]['on_hand_value'] += (p.get('qty_available') or 0.0) * (p.get('standard_price') or 0.0) |
| season_rows = [] |
| for e in seasons.values(): |
| e['n_skus'] = len(e['skus']) |
| e['codes'] = sorted((prods.get(pid, {}).get('default_code') or '').strip() |
| for pid in e['skus'] if prods.get(pid, {}).get('default_code')) |
| del e['skus'] |
| e['cover_pct'] = (e['on_hand_units'] / e['demand_units_ly'] * 100) if e['demand_units_ly'] else None |
| season_rows.append(e) |
| season_rows.sort(key=lambda x: -x['demand_rev_ly']) |
|
|
| |
| suspects = [] |
| for fkey, flabel in FACETS: |
| vals = {} |
| for p in prods.values(): |
| v = str(p.get(fkey) or '').strip() |
| if v: |
| vals[v] = vals.get(v, 0) + 1 |
| suspects += [{'facet': flabel, 'value': v, 'skus': n} for v, n in vals.items() if n < 3] |
|
|
| return {'dims': dims, 'season': season_rows, 'suspects': sorted(suspects, key=lambda x: x['skus']), |
| 'total_rev': total_rev, 'faceted_rev': faceted_rev, |
| 'faceted_share': (faceted_rev / total_rev * 100) if total_rev else None, |
| 'n_faceted': n_faceted, 'n_products': len(prods), |
| 'ytd': (yf, yt), 'ly': (lf, lt)} |
|
|
|
|
| def validate(t=None, team_id=None, pre=None): |
| t = t or P.today() |
| b = pre or build(team_id, t) |
| yf, yt = b['ytd'] |
| total = O.sum_field('sale.order.line', O.sale_line_domain(yf, yt, team_id), 'price_subtotal') |
| checks = [] |
| for fkey, flabel in FACETS[:2]: |
| s = sum(r['rev'] for r in b['dims'][fkey]['rows']) |
| checks.append({'check': f'Assortment: Σ({flabel} facet rev) == total line revenue (YTD)', |
| 'a': round(s, 2), 'b': round(total, 2), 'gap': round(s - total, 2), |
| 'ok': abs(s - total) <= max(1.0, abs(total) * 0.001)}) |
| fs = b['faceted_rev'] + sum(r['rev'] for r in b['dims']['x_main']['rows'] |
| if r['value'] == UNTAGGED) |
| checks.append({'check': 'Assortment: faceted rev + untagged bucket == total (partition)', |
| 'a': round(fs, 2), 'b': round(sum(r['rev'] for r in b['dims']['x_main']['rows']), 2), |
| 'gap': round(fs - sum(r['rev'] for r in b['dims']['x_main']['rows']), 2), |
| 'ok': abs(fs - sum(r['rev'] for r in b['dims']['x_main']['rows'])) <= 1.0}) |
| return checks |
|
|