loopable / platform /modules /reprice.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
21.1 kB
"""Reprice lifecycle β€” the price-side discipline the pricing brief found missing
(.claude/wiki/research/pricing.md): rule hygiene BEFORE the reprice, realization tracking
AFTER it, and the between-waves views (pocket waterfall, peer price corridor).
Everything reads sale.order.line + product.pricelist.item + product masters, read-only.
NO elasticity claims β€” rejected at wholesale data density (see the brief's anti-patterns).
Windows: LTM for corridor/waterfall (matches pricecomp), all-channel sales for the DEAD-rule
flag (the Amazon channel-scope trap: a rule serving Amazon sales is not dead).
"""
import datetime as dt
import core.odoo as O
import core.periods as P
import modules.pricecomp as pcomp
import modules.customers as cust_mod
STALE_MONTHS = 24
MIN_CORRIDOR_BUYERS = 8
CORRIDOR_FLOOR_PCTL = 25 # floor = p25 of %-of-tier among the SKU's buyers
REALIZED_TARGET_PCT = 80.0 # an increase that landed <80% = worklist
def _chunk(ids, n=400):
ids = list(ids)
for i in range(0, len(ids), n):
yield ids[i:i + n]
def _all_channel_sold(lf, lt):
"""{product_id: qty} confirmed sales, ALL channels (never flag an Amazon seller as dead)."""
out = {}
for g in O.read_group('sale.order.line',
[('state', 'in', ['sale', 'done']), ('product_id', '!=', False),
('order_id.date_order', '>=', f'{lf} 00:00:00'),
('order_id.date_order', '<=', f'{lt} 23:59:59')],
['product_uom_qty:sum'], ['product_id'], lazy=False):
pid = O.m2o_id(g.get('product_id'))
if pid:
out[pid] = g.get('product_uom_qty') or 0.0
return out
# ================================================================ 1. RULE HYGIENE
def rule_health(t=None):
"""Every fixed pricelist rule flagged for the pre-reprice cleanup. One verb per flag:
STALE β†’ reprice it Β· DEAD β†’ archive it Β· BELOW-COST β†’ raise it NOW (before the next
order books β€” the realized below-cost view only catches it after) Β· OVERLAP β†’ resolve
precedence."""
t = t or P.today()
lf, lt = P.ltm(t)
stale_cut = (t - dt.timedelta(days=STALE_MONTHS * 30)).isoformat()
today = t.isoformat()
rules = O.search_read(
'product.pricelist.item',
[('compute_price', '=', 'fixed'),
('applied_on', 'in', ['0_product_variant', '1_product'])],
['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on', 'fixed_price',
'min_quantity', 'date_start', 'date_end', 'write_date'])
# resolve template rules to variants; pull cost + active per involved variant
tmpl_ids = {O.m2o_id(r['product_tmpl_id']) for r in rules
if r['applied_on'] == '1_product' and r.get('product_tmpl_id')}
var_ids = {O.m2o_id(r['product_id']) for r in rules
if r['applied_on'] == '0_product_variant' and r.get('product_id')}
by_tmpl, vinfo = {}, {}
for ch in _chunk(list(tmpl_ids) | set() if not tmpl_ids else list(tmpl_ids), 2000):
for p in O.search_read('product.product',
[('product_tmpl_id', 'in', ch), ('active', 'in', [True, False])],
['product_tmpl_id', 'standard_price', 'active',
'default_code']):
by_tmpl.setdefault(O.m2o_id(p['product_tmpl_id']), []).append(p)
vinfo[p['id']] = p
for ch in _chunk([v for v in var_ids if v not in vinfo], 2000):
for p in O.search_read('product.product',
[('id', 'in', ch), ('active', 'in', [True, False])],
['product_tmpl_id', 'standard_price', 'active',
'default_code']):
vinfo[p['id']] = p
sold = _all_channel_sold(lf, lt)
def _variants_of(r):
if r['applied_on'] == '0_product_variant':
v = vinfo.get(O.m2o_id(r.get('product_id')))
return [v] if v else []
return by_tmpl.get(O.m2o_id(r.get('product_tmpl_id')), [])
# overlap detection: same (pricelist, target, qty break) with overlapping date windows
def _target_key(r):
return (O.m2o_id(r['pricelist_id']), r['applied_on'],
O.m2o_id(r.get('product_id')) or O.m2o_id(r.get('product_tmpl_id')),
r.get('min_quantity') or 0)
seen_keys = {}
for r in rules:
seen_keys.setdefault(_target_key(r), []).append(r)
def _windows_overlap(a, b):
a0 = str(a.get('date_start') or '')[:10] or '0000'
a1 = str(a.get('date_end') or '')[:10] or '9999'
b0 = str(b.get('date_start') or '')[:10] or '0000'
b1 = str(b.get('date_end') or '')[:10] or '9999'
return a0 <= b1 and b0 <= a1
rows = []
counts = {'stale': 0, 'dead': 0, 'below_cost': 0, 'overlap': 0}
below_cost_gap = 0.0
for r in rules:
de = str(r.get('date_end') or '')[:10]
if de and de < today:
continue # already expired β€” not operating
variants = _variants_of(r)
v_sold = sum(sold.get(v['id'], 0.0) for v in variants)
any_active = any(v.get('active') for v in variants)
# cost basis: the CHEAPEST variant β€” flags only when price is below even that
costs = [v.get('standard_price') or 0.0 for v in variants
if (v.get('standard_price') or 0.0) > 0]
min_cost = min(costs) if costs else None
price = r.get('fixed_price') or 0.0
code = next(((v.get('default_code') or '').strip() for v in variants
if (v.get('default_code') or '').strip()), '')
flags = []
if not variants or not any_active or (any_active and v_sold <= 0):
if not variants or not any_active:
flags.append(('dead', 'Archive rule β€” product archived/missing'))
elif v_sold <= 0:
flags.append(('dead', 'Archive rule β€” no sales in 12m (all channels)'))
if str(r.get('write_date') or '')[:10] < stale_cut and v_sold > 0:
flags.append(('stale', f'Reprice β€” untouched >{STALE_MONTHS}m, still selling'))
if min_cost is not None and price > 0 and price < min_cost and v_sold > 0:
flags.append(('below_cost', 'Raise NOW β€” tier below current unit cost'))
below_cost_gap += (min_cost - price) * v_sold
siblings = seen_keys.get(_target_key(r), [])
if len(siblings) > 1 and any(s is not r and _windows_overlap(r, s)
for s in siblings):
flags.append(('overlap', 'Resolve precedence β€” duplicate qty-break rule'))
if not flags:
continue
for kind, _v in flags:
counts[kind] += 1
rows.append({
'rule_id': r['id'], 'pricelist': O.m2o_name(r['pricelist_id']),
'sku': code,
'product': O.m2o_name(r.get('product_id')) or O.m2o_name(r.get('product_tmpl_id')),
'qty_break': r.get('min_quantity') or 0, 'price': price,
'unit_cost': min_cost if min_cost is not None else '',
'sold_12m': v_sold, 'last_touched': str(r.get('write_date') or '')[:10],
'flags': ', '.join(k for k, _ in flags),
'action': flags[0][1],
})
rows.sort(key=lambda x: (0 if 'below_cost' in x['flags'] else 1, -(x['sold_12m'] or 0)))
return {'rows': rows, 'n_rules': len(rules), 'counts': counts,
'below_cost_gap': below_cost_gap, 'window': (lf, lt)}
# ================================================================ 2. REALIZATION TRACKER
def realization_events(t=None, months_back=15, min_rules=15):
"""Candidate reprice events = days on which many fixed rules were (re)written. Odoo keeps
no price history, so the event DATE is the anchor and the CURRENT rule price is the target."""
t = t or P.today()
since = (t - dt.timedelta(days=months_back * 30)).isoformat()
rules = O.search_read('product.pricelist.item',
[('compute_price', '=', 'fixed'),
('applied_on', 'in', ['0_product_variant', '1_product']),
('write_date', '>=', since)],
['write_date', 'product_id', 'product_tmpl_id'])
per_day = {}
for r in rules:
d = str(r.get('write_date') or '')[:10]
if d:
e = per_day.setdefault(d, {'n_rules': 0, 'targets': set()})
e['n_rules'] += 1
e['targets'].add(O.m2o_id(r.get('product_id'))
or O.m2o_id(r.get('product_tmpl_id')))
out = [{'date': d, 'n_rules': v['n_rules'], 'n_products': len(v['targets'])}
for d, v in per_day.items() if v['n_rules'] >= min_rules]
out.sort(key=lambda x: x['date'], reverse=True)
return out
def _avg_price_by_product(prod_ids, a, b, team_id=None):
"""{product_id: (avg unit price, qty)} over a window β€” chunked (large in-domains make
grouped reads echo the domain per group and MemoryError server-side)."""
out = {}
for ch in _chunk(prod_ids):
dom = O.sale_line_domain(a, b, team_id, extra=[('product_id', 'in', ch)])
for g in O.read_group('sale.order.line', dom,
['price_subtotal:sum', 'product_uom_qty:sum'],
['product_id'], lazy=False):
pid = O.m2o_id(g.get('product_id'))
qty = g.get('product_uom_qty') or 0.0
rev = g.get('price_subtotal') or 0.0
if pid and qty > 0:
out[pid] = (rev / qty, qty)
return out
def realization(event_date, t=None, team_id=None, before_days=180, after_min_days=30):
"""Did the increase STICK? For every product whose rule was written on `event_date`:
realized avg before (180d) vs after (event β†’ today) vs the CURRENT rule target.
realization % = (after βˆ’ before) / (target βˆ’ before). <80% at scale = the worklist."""
t = t or P.today()
ev = dt.date.fromisoformat(event_date)
if (t - ev).days < after_min_days:
return {'rows': [], 'note': f'event only {(t - ev).days}d old β€” too early to score',
'event': event_date}
rules = O.search_read('product.pricelist.item',
[('compute_price', '=', 'fixed'),
('applied_on', 'in', ['0_product_variant', '1_product']),
('write_date', '>=', f'{event_date} 00:00:00'),
('write_date', '<=', f'{event_date} 23:59:59')],
['product_id', 'product_tmpl_id', 'applied_on', 'fixed_price',
'min_quantity'])
# base tier target per product (lowest qty break); template rules β†’ variants
tmpl_ids = [O.m2o_id(r['product_tmpl_id']) for r in rules
if r['applied_on'] == '1_product' and r.get('product_tmpl_id')]
t2v = {}
for ch in _chunk(tmpl_ids, 2000):
for p in O.search_read('product.product',
[('product_tmpl_id', 'in', ch), ('active', 'in', [True, False])],
['product_tmpl_id', 'default_code']):
t2v.setdefault(O.m2o_id(p['product_tmpl_id']), []).append(p)
target, codes = {}, {}
for r in sorted(rules, key=lambda x: x.get('min_quantity') or 0, reverse=True):
price = r.get('fixed_price') or 0.0
if price <= 0:
continue
if r['applied_on'] == '0_product_variant' and r.get('product_id'):
target[O.m2o_id(r['product_id'])] = price # lowest break wins (reverse sort)
else:
for p in t2v.get(O.m2o_id(r.get('product_tmpl_id')), []):
target[p['id']] = price
codes[p['id']] = (p.get('default_code') or '').strip()
prods = list(target)
if not prods:
return {'rows': [], 'note': 'no fixed rules found on that date', 'event': event_date}
for ch in _chunk([p for p in prods if p not in codes], 2000):
for p in O.search_read('product.product',
[('id', 'in', ch), ('active', 'in', [True, False])],
['default_code', 'name']):
codes[p['id']] = (p.get('default_code') or '').strip()
b0 = (ev - dt.timedelta(days=before_days)).isoformat()
b1 = (ev - dt.timedelta(days=1)).isoformat()
before = _avg_price_by_product(prods, b0, b1, team_id)
after = _avg_price_by_product(prods, event_date, t.isoformat(), team_id)
rows = []
for pid in prods:
if pid not in before or pid not in after:
continue
p0, _q0 = before[pid]
p1, q1 = after[pid]
tgt = target[pid]
if tgt <= p0 * 1.005:
continue # not an increase vs realized base β€” skip
realized_pct = (p1 - p0) / (tgt - p0) * 100.0
days_since = max((t - ev).days, 1)
run_rate = q1 / days_since * 365.0
rows.append({'prod': pid, 'sku': codes.get(pid, f'#{pid}'),
'before': p0, 'after': p1, 'target': tgt,
'realized_pct': realized_pct, 'qty_after': q1,
'missing': max(0.0, (tgt - p1)) * run_rate})
rows.sort(key=lambda x: -x['missing'])
n = len(rows)
landed = sum(1 for r in rows if r['realized_pct'] >= REALIZED_TARGET_PCT)
return {'rows': rows, 'event': event_date, 'n_scored': n,
'n_landed': landed,
'landed_pct': (landed / n * 100.0) if n else None,
'missing_total': sum(r['missing'] for r in rows),
'note': None}
# ================================================================ 3. CORRIDOR + WATERFALL
def corridor(team_id=None, t=None, pre=None):
"""Peer price corridor: within each SKU (β‰₯MIN_CORRIDOR_BUYERS tier-matched buyers), the
floor = p{CORRIDOR_FLOOR_PCTL} of realized-%-of-tier. A customer below the floor AND below
95% of tier is off-corridor even if 'on' their assigned tier β€” the tier ASSIGNMENT review.
Uplift = (floor βˆ’ actual) Γ— tier Γ— qty."""
mp = pre or pcomp.matched_pairs(team_id, t)
by_prod = {}
for p in mp['pairs']:
if p.get('tier'):
by_prod.setdefault(p['prod'], []).append(p)
flags, n_skus = [], 0
for prod, plist in by_prod.items():
if len(plist) < MIN_CORRIDOR_BUYERS:
continue
n_skus += 1
ratios = sorted(x['unit'] / x['tier'] for x in plist)
floor = ratios[max(0, int(len(ratios) * CORRIDOR_FLOOR_PCTL / 100) - 1)]
for x in plist:
ratio = x['unit'] / x['tier']
if ratio < floor and ratio < 0.95:
flags.append({'pid': x['pid'], 'customer': x['customer'],
'sku': x['sku'], 'product': x['product'],
'pct_of_tier': ratio * 100.0, 'floor_pct': floor * 100.0,
'qty_ltm': x['qty'],
'uplift': (floor - ratio) * x['tier'] * x['qty']})
flags.sort(key=lambda x: -x['uplift'])
per_cust = {}
for f in flags:
e = per_cust.setdefault(f['pid'], {'pid': f['pid'], 'customer': f['customer'],
'skus': 0, 'uplift': 0.0})
e['skus'] += 1
e['uplift'] += f['uplift']
by_customer = sorted(per_cust.values(), key=lambda x: -x['uplift'])
return {'flags': flags, 'by_customer': by_customer, 'n_skus_scored': n_skus,
'uplift_total': sum(f['uplift'] for f in flags), 'window': mp['window']}
def waterfall(team_id=None, t=None, pre=None):
"""Honest pocket-price waterfall, LTM, tier-matched pairs only (coverage stated):
tier list value β†’ βˆ’ below-tier selling β†’ + above-tier selling β†’ invoice value
β†’ βˆ’ credit notes (company-level, not BU-taggable) β†’ pocket. Stages with no data are
DROPPED, never imputed."""
mp = pre or pcomp.matched_pairs(team_id, t)
lf, lt = mp['window']
matched = [p for p in mp['pairs'] if p.get('tier')]
tier_value = sum(p['tier'] * p['qty'] for p in matched)
invoice_value = sum(p['rev'] for p in matched)
below = sum(max(0.0, (p['tier'] - p['unit'])) * p['qty'] for p in matched)
above = sum(max(0.0, (p['unit'] - p['tier'])) * p['qty'] for p in matched)
matched_rev_share = (invoice_value /
sum(p['rev'] for p in mp['pairs']) * 100.0) if mp['pairs'] else 0.0
# credit notes, LTM, product lines (company-level β€” honesty note in the UI)
refunds = O.sum_field('account.move.line',
[('move_id.move_type', '=', 'out_refund'),
('parent_state', '=', 'posted'),
('move_id.invoice_date', '>=', lf),
('move_id.invoice_date', '<=', lt),
('product_id', '!=', False)], 'price_subtotal')
stages = [{'stage': 'Tier list value', 'amount': tier_value, 'kind': 'base'}]
if below > 0:
stages.append({'stage': 'Below-tier selling', 'amount': -below, 'kind': 'leak'})
if above > 0:
stages.append({'stage': 'Above-tier selling', 'amount': above, 'kind': 'gain'})
stages.append({'stage': 'Invoice value', 'amount': invoice_value, 'kind': 'subtotal'})
if refunds:
stages.append({'stage': 'Credit notes (company)', 'amount': -refunds, 'kind': 'leak'})
stages.append({'stage': 'Pocket value', 'amount': invoice_value - (refunds or 0.0),
'kind': 'total'})
return {'stages': stages, 'tier_value': tier_value, 'invoice_value': invoice_value,
'below': below, 'above': above, 'refunds': refunds or 0.0,
'matched_rev_share': matched_rev_share, 'window': (lf, lt)}
def below_tier_detail(team_id=None, t=None, pre=None):
"""The waterfall's below-tier bar, drilled: every pair sold under its tier (the FULL gap,
not just the <97% compliance threshold), rolled up by customer / SKU / agent. Each rollup's
total reproduces the waterfall stage to the cent (rule 8b β€” no unverifiable aggregates)."""
mp = pre or pcomp.matched_pairs(team_id, t)
below = [p for p in mp['pairs'] if p.get('tier') and p['unit'] < p['tier']]
attrs = cust_mod._partner_attrs(list({p['pid'] for p in below}))
by_cust, by_sku, by_agent = {}, {}, {}
for p in below:
gap = (p['tier'] - p['unit']) * p['qty']
c = by_cust.setdefault(p['pid'], {'pid': p['pid'], 'customer': p['customer'],
'skus': 0, 'gap': 0.0, 'rev': 0.0})
c['skus'] += 1
c['gap'] += gap
c['rev'] += p['rev']
s = by_sku.setdefault(p['prod'], {'code': p['sku'] or f"#{p['prod']}",
'product': p['product'], 'customers': 0,
'gap': 0.0, 'rev': 0.0})
s['customers'] += 1
s['gap'] += gap
s['rev'] += p['rev']
a_name = (attrs.get(p['pid']) or {}).get('agent') or '(none)'
a = by_agent.setdefault(a_name, {'agent': a_name, 'pairs': 0, 'gap': 0.0})
a['pairs'] += 1
a['gap'] += gap
out = {
'by_customer': sorted(by_cust.values(), key=lambda x: -x['gap']),
'by_sku': sorted(by_sku.values(), key=lambda x: -x['gap']),
'by_agent': sorted(by_agent.values(), key=lambda x: -x['gap']),
'total': sum((p['tier'] - p['unit']) * p['qty'] for p in below),
'n_pairs': len(below),
}
return out
# ================================================================ VALIDATION
def validate(t=None, team_id=None, pre_mp=None):
"""(1) rule universe ties search_count; (2) waterfall arithmetic closes exactly:
tier βˆ’ below + above == invoice (same pairs, two computation paths)."""
n = O.get_odoo().search_count('product.pricelist.item',
[('compute_price', '=', 'fixed'),
('applied_on', 'in', ['0_product_variant', '1_product'])])
rh = rule_health(t)
checks = [{'check': 'pricelist rules β€” pull complete',
'a': rh['n_rules'], 'b': n, 'gap': rh['n_rules'] - n,
'ok': rh['n_rules'] == n}]
wf = waterfall(team_id, t, pre=pre_mp)
lhs = wf['tier_value'] - wf['below'] + wf['above']
checks.append({'check': 'waterfall closes: tier βˆ’ below + above == invoice',
'a': round(lhs, 2), 'b': round(wf['invoice_value'], 2),
'gap': round(lhs - wf['invoice_value'], 2),
'ok': abs(lhs - wf['invoice_value']) < 1.0})
bd = below_tier_detail(team_id, t, pre=pre_mp)
for key in ('by_customer', 'by_sku', 'by_agent'):
s = sum(r['gap'] for r in bd[key])
checks.append({'check': f'below-tier drill {key} == the waterfall bar',
'a': round(s, 2), 'b': round(wf['below'], 2),
'gap': round(s - wf['below'], 2),
'ok': abs(s - wf['below']) < 1.0})
return checks