loopable / platform /modules /pricecomp.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
8.33 kB
"""Price compliance — selling below the customer's pricelist tier (pocket-price floor).
Discount FIELDS are unused in this Odoo (verified dead), but reps can still key a low
price_unit directly — leakage that is invisible today. Per (customer × SKU) over the LTM:
realized unit price (Σ subtotal ÷ Σ qty — discount-true) vs the customer's applicable
pricelist tier (fixed-price rules, qty break matched on the average order quantity).
Exceptions below THRESHOLD of tier form the worklist, each valued at its annualized leak
(= (tier − realized) × LTM qty). Pricing canon: ~1% realization ≈ 8-9% operating profit.
v1 simplifications (documented, deliberate):
- qty break matched on AVG order qty per (customer, SKU) — not per order line;
- fixed-price rules only (this instance prices via fixed tiers, like the legacy SKU app);
- LTM window, wholesale scope.
"""
import datetime as dt
import core.odoo as O
import core.periods as P
import modules.customers as cust
THRESHOLD = 0.97 # below 97% of tier = exception
def _rules():
"""All date-valid fixed-price pricelist rules, indexed by (pricelist, product-variant)
and (pricelist, product-template). Highest min_quantity ≤ qty wins at match time."""
today = P.today().isoformat()
rows = O.search_read(
'product.pricelist.item',
[('compute_price', '=', 'fixed'),
('applied_on', 'in', ['0_product_variant', '1_product'])],
['pricelist_id', 'product_id', 'product_tmpl_id', 'min_quantity',
'fixed_price', 'date_start', 'date_end', 'applied_on'])
ok = []
for r in rows:
ds = str(r.get('date_start') or '')[:10]
de = str(r.get('date_end') or '')[:10]
if (ds and ds > today) or (de and de < today):
continue
ok.append(r)
by_var, by_tmpl = {}, {}
for r in ok:
pl = O.m2o_id(r['pricelist_id'])
if r['applied_on'] == '0_product_variant' and r.get('product_id'):
by_var.setdefault((pl, O.m2o_id(r['product_id'])), []).append(r)
elif r.get('product_tmpl_id'):
by_tmpl.setdefault((pl, O.m2o_id(r['product_tmpl_id'])), []).append(r)
return ok, by_var, by_tmpl
def _tier_for(pl, pid, tmpl, qty, by_var, by_tmpl):
"""The applicable fixed tier price: variant rules beat template rules; within a scope,
the highest qty break ≤ qty wins."""
for cands in (by_var.get((pl, pid)), by_tmpl.get((pl, tmpl))):
if not cands:
continue
eligible = [r for r in cands if (r.get('min_quantity') or 0) <= max(qty, 1)]
if eligible:
best = max(eligible, key=lambda r: r.get('min_quantity') or 0)
return best.get('fixed_price') or None
return None
def matched_pairs(team_id=None, t=None):
"""The shared substrate: every (customer × SKU) pair bought in the LTM with its applicable
fixed tier attached (tier=None when the customer has no pricelist / no rule matches).
Used by the compliance sweep here AND by the price corridor + pocket waterfall
(modules/reprice.py) — one definition of 'realized vs tier' everywhere."""
t = t or P.today()
lf, lt = P.ltm(t)
# realized price per (customer, SKU) — one server-side aggregate
g = O.read_group('sale.order.line', O.sale_line_domain(lf, lt, team_id),
['price_subtotal:sum', 'product_uom_qty:sum'],
['order_partner_id', 'product_id'], lazy=False)
pairs = []
for r in g:
pid = O.m2o_id(r.get('order_partner_id'))
prod = O.m2o_id(r.get('product_id'))
qty = r.get('product_uom_qty') or 0.0
rev = r.get('price_subtotal') or 0.0
n = r.get('__count') or 1
if pid and prod and qty > 0 and rev > 0:
pairs.append({'pid': pid, 'customer': O.m2o_name(r.get('order_partner_id')),
'prod': prod, 'product': O.m2o_name(r.get('product_id')),
'qty': qty, 'rev': rev, 'lines': n,
'unit': rev / qty, 'avg_order_qty': qty / n})
# customer pricelists + product template/code lookups
pids = list({p['pid'] for p in pairs})
plists = {}
for i in range(0, len(pids), 2000):
for r in O.search_read('res.partner', [('id', 'in', pids[i:i + 2000])],
['property_product_pricelist']):
plists[r['id']] = O.m2o_id(r.get('property_product_pricelist'))
prods = list({p['prod'] for p in pairs})
pinfo = {}
for i in range(0, len(prods), 2000):
for r in O.search_read('product.product',
[('id', 'in', prods[i:i + 2000]),
('active', 'in', [True, False])],
['product_tmpl_id', 'default_code']):
pinfo[r['id']] = {'tmpl': O.m2o_id(r.get('product_tmpl_id')),
'code': (r.get('default_code') or '').strip()}
all_rules, by_var, by_tmpl = _rules()
for p in pairs:
info = pinfo.get(p['prod'], {})
p['sku'] = info.get('code', '')
pl = plists.get(p['pid'])
tier = (_tier_for(pl, p['prod'], info.get('tmpl'), p['avg_order_qty'],
by_var, by_tmpl) if pl else None)
p['tier'] = tier if (tier and tier > 0) else None
return {'pairs': pairs, 'checked': len(pairs), 'n_rules': len(all_rules),
'window': (lf, lt)}
def build(team_id=None, t=None, pre=None):
"""The compliance sweep. Returns dict(rows[exceptions], checked, matched, kpis…)."""
mp = pre or matched_pairs(team_id, t)
matched_list = [p for p in mp['pairs'] if p.get('tier')]
exceptions = [p for p in matched_list if p['unit'] / p['tier'] < THRESHOLD]
attrs = cust._partner_attrs(list({p['pid'] for p in exceptions}))
rows = []
for p in exceptions:
tier = p['tier']
rows.append({
'pid': p['pid'], 'customer': p['customer'],
'sku': p['sku'], 'product': p['product'],
'agent': (attrs.get(p['pid']) or {}).get('agent', '(none)'),
'tier_price': tier, 'realized': p['unit'],
'pct_of_tier': p['unit'] / tier * 100.0,
'qty_ltm': p['qty'], 'rev_ltm': p['rev'],
'leak': (tier - p['unit']) * p['qty'], 'lines': p['lines'],
})
rows.sort(key=lambda x: -x['leak'])
return {
'rows': rows, 'checked': mp['checked'], 'matched': len(matched_list),
'n_rules': mp['n_rules'],
'leak_total': sum(r['leak'] for r in rows),
'exception_rate': (len(rows) / len(matched_list) * 100.0) if matched_list else 0.0,
'window': mp['window'],
}
def validate(t=None, team_id=None):
"""(1) rule pull complete vs search_count; (2) the TOP exception re-derived row-level:
its realized unit price recomputed from its raw order lines (independent path) must match
the read_group aggregate the sweep used."""
b = build(team_id, t)
n = O.get_odoo().search_count(
'product.pricelist.item',
[('compute_price', '=', 'fixed'),
('applied_on', 'in', ['0_product_variant', '1_product'])])
checks = [{'check': 'fixed pricelist rules — pull complete (pre date-filter)',
'a': b['n_rules'], 'b': f'<= {n}', 'gap': '',
'ok': b['n_rules'] <= n}]
if b['rows']:
top = b['rows'][0]
lf, lt = b['window']
lines = O.search_read('sale.order.line',
O.sale_line_domain(lf, lt, team_id,
partner_ids=[top['pid']],
extra=[('product_id.default_code', '=',
top['sku'])]),
['price_subtotal', 'product_uom_qty'])
qty = sum(l.get('product_uom_qty') or 0 for l in lines)
rev = sum(l.get('price_subtotal') or 0 for l in lines)
unit = rev / qty if qty else 0.0
checks.append({'check': f"top exception realized price — {top['sku']}",
'a': round(unit, 4), 'b': round(top['realized'], 4),
'gap': round(unit - top['realized'], 4),
'ok': abs(unit - top['realized']) < 0.01})
return checks