loopable / platform /modules /assortment.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
7.59 kB
"""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)
# ---- facet rollups (every dimension partitions the same YTD revenue) -------------------
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}
# ---- coverage: how much of revenue is on FACETED (x_main-tagged) SKUs ------------------
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'))
# ---- season readiness: units sold in [today, +180d] LAST YEAR per season vs on hand ----
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'])
# suspect facet hygiene: values carried by <3 SKUs across ALL products (typos like 'vgsd')
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]: # every dimension partitions the SAME revenue; check two
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