"""Overstock & dead-stock module (lives under Inventory). A deeper, book-value-accurate lens on trapped inventory capital than the standard-cost coverage view in inventory.py. Three things: 1. DEEP per-SKU list — every in-stock SKU with its BOOK value (cumulative stock.valuation.layer, which ties to GL account 05000), days-of-inventory (DSI = on-hand / last-3mo annualised ALL-CHANNEL sales), a dead/excess/slow/healthy status, last-sold month and trailing units. 2. SUMMARY — trapped capital = dead + excess; share of inventory; bucket distribution. 3. ROLLING COHORT RECOVERY — each month-end classifies its dead cohort, then tracks it forward; a SKU "graduates" only when DSI < 120 days. Quantifies how little dead stock self-clears. CONSOLIDATED (HQ): on-hand stock is one physical warehouse, not brand-tagged — ignores the DBA filter, like the rest of Inventory. ALL-CHANNEL sales (no team/partner scope) are used on purpose: a SKU that sells only through the Amazon/GIFTWARE channel is NOT dead. READ-ONLY. """ import sys import calendar import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import core.odoo as O DEAD_UNITS_12M = 5 # < this many units sold (all channels) in trailing 12mo = dead GRADUATE_DSI = 120 # days-of-inventory below this = healthy turnover ("graduated") EXCESS_DSI = 365 # sells, but holds > 1 year of supply = excess/overstock _VAL_FLOOR = 50.0 # ignore sub-$50 book scraps as noise # --------------------------------------------------------------------------- date helpers def _today(): return datetime.date.today() def _eom(y, m): return datetime.date(y, m, calendar.monthrange(y, m)[1]) def _seq(y0, m0, y1, m1): out, y, m = [], y0, m0 while (y, m) <= (y1, m1): out.append((y, m)); y, m = (y + (m // 12)), (m % 12 + 1) return out # --------------------------------------------------------------------------- raw pulls def _monthly_sales(yms): """All-channel confirmed sales UNITS per product per (y,m). Parallelised — one read_group/month.""" def _one(ym): y, m = ym f = f"{y}-{m:02d}-01 00:00:00"; t = f"{y}-{m:02d}-{calendar.monthrange(y, m)[1]} 23:59:59" g = O.read_group('sale.order.line', [('order_id.state', 'in', ['sale', 'done']), ('order_id.date_order', '>=', f), ('order_id.date_order', '<=', t), ('product_id.type', '!=', 'service')], ['product_uom_qty:sum', 'product_id'], ['product_id'], lazy=False) return {O.m2o_id(r['product_id']): (r.get('product_uom_qty') or 0.0) for r in g if r.get('product_id')} res = O.parallel([(lambda ym=ym: _one(ym)) for ym in yms]) return dict(zip(yms, res)) def _onhand(asof): """Cumulative on-hand (qty, book value) per product from stock.valuation.layer as of `asof` (a 'YYYY-MM-DD' date). Book value ties to GL 05000 (validated).""" g = O.read_group('stock.valuation.layer', [('create_date', '<=', f'{asof} 23:59:59')], ['value:sum', 'quantity:sum', 'product_id'], ['product_id'], lazy=False) return {O.m2o_id(r['product_id']): ((r.get('quantity') or 0.0), (r.get('value') or 0.0)) for r in g if r.get('product_id')} def _cat_main(catid_map, categ): cid = O.m2o_id(categ) return catid_map.get(cid) or '(uncategorized)' def _master(pids): """Product code/name/category for a set of ids (incl. archived — re-SKUed items).""" if not pids: return {}, {} prods = O.search_read('product.product', [('id', 'in', list(pids)), ('active', 'in', [True, False])], ['default_code', 'name', 'categ_id']) cats = O.search_read('product.category', [], ['id', 'complete_name']) catmap = {} for c in cats: parts = [x.strip() for x in (c['complete_name'] or '').split('/')] catmap[c['id']] = parts[1] if len(parts) >= 2 else (parts[0] if parts else None) pm = {p['id']: {'sku': (p.get('default_code') or f"#{p['id']}"), 'name': p.get('name') or '', 'category': _cat_main(catmap, p.get('categ_id'))} for p in prods} return pm, catmap # --------------------------------------------------------------------------- classification def _dsi(on_hand, units_3m): if units_3m > 0: return on_hand / (units_3m / 91.25) return None # no recent sales -> effectively infinite cover def _status(units_12m, dsi): if units_12m < DEAD_UNITS_12M: return 'Dead (no sales 12mo)' if dsi is None or dsi > EXCESS_DSI: return 'Excess (>365d cover)' if dsi > GRADUATE_DSI: return 'Slow (120-365d)' return 'Healthy (<120d)' def deep(asof=None): """The deep snapshot: per-SKU rows + summary KPIs + bucket distribution, as of `asof` (default today).""" asof = asof or _today().isoformat() y, m = int(asof[:4]), int(asof[5:7]) yms = _seq(*(lambda d: (d.year, d.month))(_eom(y, m).replace(day=1) - datetime.timedelta(days=365)), y, m) # 13 months incl. current sales = _monthly_sales(yms) onhand = _onhand(asof) pids = [pid for pid, (q, v) in onhand.items() if q > 0.5 and v > 0] pm, _ = _master(pids) def trailing(pid, n): return sum(sales.get(ym, {}).get(pid, 0.0) for ym in yms[-n:]) def last_sold(pid): for ym in reversed(yms): if sales.get(ym, {}).get(pid, 0.0) > 0: return f"{calendar.month_abbr[ym[1]]} {ym[0]}" return '12+ mo ago' rows = [] for pid in pids: q, v = onhand[pid] u12, u3 = trailing(pid, 12), trailing(pid, 3) dsi = _dsi(q, u3) meta = pm.get(pid, {}) rows.append({ 'product_id': pid, 'sku': meta.get('sku', f'#{pid}'), 'code': meta.get('sku', f'#{pid}'), 'product': meta.get('name', ''), 'name': meta.get('name', ''), 'category': meta.get('category', '(uncategorized)'), 'on_hand': float(q), 'book_value': float(v), 'unit_cost': float(v / q) if q else 0.0, 'units_12mo': float(u12), 'units_3mo': float(u3), 'dsi': (None if dsi is None else round(dsi, 0)), 'last_sold': last_sold(pid), 'status': _status(u12, dsi), }) rows.sort(key=lambda r: -r['book_value']) total = sum(r['book_value'] for r in rows) def grp(pred): sub = [r for r in rows if pred(r)] return len(sub), sum(r['book_value'] for r in sub) dn, dv = grp(lambda r: r['status'].startswith('Dead')) en, ev = grp(lambda r: r['status'].startswith('Excess')) sn, sv = grp(lambda r: r['status'].startswith('Slow')) hn, hv = grp(lambda r: r['status'].startswith('Healthy')) buckets = [{'status': lbl, 'skus': n, 'book_value': val, 'share': (val / total * 100 if total else 0)} for lbl, (n, val) in [('Dead (no sales 12mo)', (dn, dv)), ('Excess (>365d cover)', (en, ev)), ('Slow (120-365d)', (sn, sv)), ('Healthy (<120d)', (hn, hv))]] summary = { 'asof': asof, 'window': f'{yms[0][0]}-{yms[0][1]:02d} → {yms[-1][0]}-{yms[-1][1]:02d}', 'total_book': total, 'trapped_value': dv + ev, 'trapped_skus': dn + en, 'trapped_share': ((dv + ev) / total * 100 if total else 0), 'dead_value': dv, 'dead_skus': dn, 'excess_value': ev, 'excess_skus': en, 'slow_value': sv, 'slow_skus': sn, 'healthy_value': hv, 'healthy_skus': hn, } return {'rows': rows, 'summary': summary, 'buckets': buckets} # --------------------------------------------------------------------------- rolling cohort recovery def cohort_recovery(asof=None): """For each month-end (Jan-2025 → last full month) classify the dead cohort, then track it forward: a SKU graduates when DSI < 120. Returns the average recovery curve + per-cohort summary.""" asof = asof or _today() if isinstance(asof, str): asof = datetime.date.fromisoformat(asof) last = asof.replace(day=1) - datetime.timedelta(days=1) # last complete month cohorts = _seq(2025, 1, last.year, last.month) smon = _seq(2024, 1, last.year, last.month) # trailing-12 needs a year of runway sales = _monthly_sales(smon) def trail(pid, ym, n): i = smon.index(ym) return sum(sales.get(smon[j], {}).get(pid, 0.0) for j in range(max(0, i - n + 1), i + 1)) # on-hand (qty, value) at each cohort month-end, in parallel ends = [_eom(y, m).isoformat() for (y, m) in cohorts] states = dict(zip(cohorts, O.parallel([(lambda e=e: _onhand(e)) for e in ends]))) # per (cohort-month, pid): value, dead?, dsi flat = {} for ym in cohorts: d = {} for pid, (q, v) in states[ym].items(): if q <= 0.5 or v <= 0: continue dsi = _dsi(q, trail(pid, ym, 3)) d[pid] = (v, (v > _VAL_FLOOR and trail(pid, ym, 12) < DEAD_UNITS_12M), dsi) flat[ym] = d idx = {c: i for i, c in enumerate(cohorts)} def graduated_by(pid, c, k): for t in cohorts[idx[c]:idx[c] + k + 1]: st = flat[t].get(pid) if st and st[2] is not None and st[2] < GRADUATE_DSI: return True return False curve_num, curve_den = {}, {} rows = [] for c in cohorts: cv = {pid: flat[c][pid][0] for pid in flat[c] if flat[c][pid][1]} # dead pids -> entry value tot = sum(cv.values()) if not tot: continue maxk = len(cohorts) - 1 - idx[c] for k in range(maxk + 1): rec = sum(v for pid, v in cv.items() if graduated_by(pid, c, k)) curve_num[k] = curve_num.get(k, 0.0) + rec curve_den[k] = curve_den.get(k, 0.0) + tot def pct_at(k): return (sum(v for pid, v in cv.items() if graduated_by(pid, c, k)) / tot * 100) if k <= maxk else None stuck = sum(v for pid, v in cv.items() if not graduated_by(pid, c, maxk)) rows.append({'cohort': f"{c[0]}-{c[1]:02d}", 'skus': len(cv), 'dead_value': tot, 'rec_3mo': pct_at(3), 'rec_6mo': pct_at(6), 'rec_today': (tot - stuck) / tot * 100, 'stuck_value': stuck}) curve = [{'months_since': k, 'recovered_pct': (curve_num[k] / curve_den[k] * 100 if curve_den[k] else 0)} for k in sorted(curve_num)] cd = {p['months_since']: p['recovered_pct'] for p in curve} return {'curve': curve, 'cohorts': rows, 'rec_3mo': cd.get(3), 'rec_6mo': cd.get(6), 'rec_12mo': cd.get(12), 'latest_stuck': (rows[-1]['stuck_value'] if rows else 0.0)} # --------------------------------------------------------------------------- validate def validate(asof=None): """Reconcile to independent Odoo aggregates.""" asof = asof or _today().isoformat() d = deep(asof) checks = [] # 1. layer book value (in-stock SKUs) ties to GL account 05000 balance (the audited inventory line) acc = O.search_read('account.account', [('code', '=', '05000')], ['id']) if acc: gl = O.sum_field('account.move.line', [('parent_state', '=', 'posted'), ('account_id', '=', acc[0]['id']), ('date', '<=', asof)], 'balance') ours = d['summary']['total_book'] checks.append({'check': 'On-hand book value ≈ GL 05000 Inventory', 'a': round(ours, 0), 'b': round(gl, 0), 'gap': round(ours - gl, 0), 'ok': abs(ours - gl) <= max(2500.0, abs(gl) * 0.05)}) # 2. buckets partition the total book value exactly bsum = sum(b['book_value'] for b in d['buckets']) tot = d['summary']['total_book'] checks.append({'check': 'Σ(status buckets) == total book value', 'a': round(bsum, 2), 'b': round(tot, 2), 'gap': round(bsum - tot, 2), 'ok': abs(bsum - tot) <= 1.0}) # 3. trapped = dead + excess (definition holds) s = d['summary'] checks.append({'check': 'Trapped == dead + excess value', 'a': round(s['trapped_value'], 2), 'b': round(s['dead_value'] + s['excess_value'], 2), 'gap': round(s['trapped_value'] - s['dead_value'] - s['excess_value'], 2), 'ok': abs(s['trapped_value'] - s['dead_value'] - s['excess_value']) <= 1.0}) return checks