loopable / platform /modules /backorders.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
8.72 kB
"""Backorders β€” what we owe customers, aged (the mirror image of AR aging).
Every confirmed-but-undelivered sale line, aged against the order's promise date
(commitment_date, else the earliest open outgoing picking's scheduled date), valued at the
line's discounted unit price, joined to supply (on-hand + earliest inbound PO ETA) so each
row carries ONE verb action: Ship (stock is on hand) / Expedite the PO (inbound exists) /
Call the customer (no supply in sight). Line-level fill canon: β‰₯95% industry bar.
Scope: wholesale teams (5/6), GIFTWARE/Amazon excluded β€” a backorder board is about promises
to dealers. BU-filterable.
"""
import datetime as dt
import core.odoo as O
import core.periods as P
import modules.procurement as proc
BUCKETS = ['not due', '0-7', '8-21', '22-45', '45+', 'no promise']
_OPEN_PICK_DOMAIN = [('state', 'in', ['confirmed', 'assigned', 'waiting']),
('picking_type_id.code', '=', 'outgoing')]
def _chunk(ids, n=2000):
ids = list(ids)
for i in range(0, len(ids), n):
yield ids[i:i + n]
def _open_pickings():
"""Open outgoing pickings β†’ {origin(SO name): earliest scheduled date}."""
picks = O.search_read('stock.picking', list(_OPEN_PICK_DOMAIN),
['origin', 'scheduled_date'])
sched = {}
for p in picks:
o = (p.get('origin') or '').strip()
if not o:
continue
d = str(p.get('scheduled_date') or '')[:10]
if d and (o not in sched or d < sched[o]):
sched[o] = d
return picks, sched
def board(team_id=None, t=None):
"""The full open-line board + bucket rollup. Returns dict(rows, buckets, orders, coverage)."""
t = t or P.today()
today = t
picks, sched = _open_pickings()
# the sale orders behind those pickings, in wholesale scope
orders = []
names = list(sched)
ex = O.excluded_partner_ids()
for ch in _chunk(names, 500):
dom = [('name', 'in', ch), ('state', 'in', ['sale', 'done'])]
dom.append(('team_id', '=', team_id) if team_id is not None
else ('team_id', 'in', O.TEAM_IDS))
if ex:
dom.append(('partner_id', 'not in', list(ex)))
orders += O.search_read('sale.order', dom,
['name', 'partner_id', 'team_id', 'date_order',
'commitment_date'])
by_oid = {o['id']: o for o in orders}
# their lines, open qty computed locally (a domain cannot compare two fields)
lines = []
for ch in _chunk(list(by_oid), 500):
lines += O.search_read('sale.order.line',
[('order_id', 'in', ch), ('display_type', '=', False),
('product_id', '!=', False)],
['order_id', 'order_partner_id', 'product_id',
'product_uom_qty', 'qty_delivered', 'price_unit',
'price_subtotal', 'name'])
open_lines = []
for r in lines:
openq = (r.get('product_uom_qty') or 0.0) - (r.get('qty_delivered') or 0.0)
if openq <= 1e-3:
continue
qty = r.get('product_uom_qty') or 0.0
unit = (r.get('price_subtotal') or 0.0) / qty if qty else (r.get('price_unit') or 0.0)
r['open_qty'] = openq
r['open_value'] = openq * unit
open_lines.append(r)
# supply joins: on-hand + earliest inbound ETA per product
pids = list({O.m2o_id(r['product_id']) for r in open_lines})
onhand, codes = {}, {}
for ch in _chunk(pids):
for p in O.search_read('product.product',
[('id', 'in', ch), ('active', 'in', [True, False])],
['qty_available', 'default_code']):
onhand[p['id']] = p.get('qty_available') or 0.0
codes[p['id']] = (p.get('default_code') or '').strip()
_all_pol, pol = proc._open_po_lines()
inbound = {}
for l in pol:
pid = O.m2o_id(l.get('product_id'))
if not pid:
continue
cur = inbound.setdefault(pid, {'qty': 0.0, 'eta': None})
cur['qty'] += l.get('open_qty') or 0.0
eta = l.get('expected') or ''
if eta and (cur['eta'] is None or eta < cur['eta']):
cur['eta'] = eta
rows = []
n_promised = 0
for r in open_lines:
oid = O.m2o_id(r['order_id'])
o = by_oid.get(oid) or {}
promise = str(o.get('commitment_date') or '')[:10] or sched.get(o.get('name', ''), '')
if str(o.get('commitment_date') or '')[:10]:
n_promised += 1
if promise:
days = (today - dt.date.fromisoformat(promise)).days
bucket = ('not due' if days < 0 else '0-7' if days <= 7
else '8-21' if days <= 21 else '22-45' if days <= 45 else '45+')
else:
days, bucket = None, 'no promise'
pid_prod = O.m2o_id(r['product_id'])
oh = onhand.get(pid_prod, 0.0)
inb = inbound.get(pid_prod)
if oh >= r['open_qty']:
action = 'Ship β€” stock on hand'
elif inb and inb.get('eta'):
action = f"Expedite PO β€” ETA {inb['eta']}"
else:
action = 'Call customer β€” no supply inbound'
rows.append({
'pid': O.m2o_id(r['order_partner_id']),
'customer': O.m2o_name(r['order_partner_id']),
'order': o.get('name', ''),
'bu': O.TEAM_NAMES.get(O.m2o_id(o.get('team_id')), '?'),
'sku': codes.get(pid_prod, ''), 'product': O.m2o_name(r['product_id']),
'open_qty': r['open_qty'], 'open_value': r['open_value'],
'promise': promise or 'β€”', 'days_past': days if days is not None else '',
'bucket': bucket, 'on_hand': oh,
'inbound_eta': (inb or {}).get('eta') or '',
'action': action,
})
rows.sort(key=lambda x: -x['open_value'])
bucket_rollup = []
for bu in sorted({r['bu'] for r in rows}):
for b in BUCKETS:
v = sum(r['open_value'] for r in rows if r['bu'] == bu and r['bucket'] == b)
if v:
bucket_rollup.append({'bu': bu, 'bucket': b, 'value': v})
return {
'rows': rows,
'buckets': bucket_rollup,
'n_orders': len({r['order'] for r in rows}),
'open_value': sum(r['open_value'] for r in rows),
'past_value': sum(r['open_value'] for r in rows
if r['bucket'] in ('0-7', '8-21', '22-45', '45+')),
'promise_coverage': (n_promised / len(open_lines) * 100.0) if open_lines else 0.0,
'n_pickings': len(picks),
}
def validate(t=None, team_id=None):
"""(1) the picking pull is complete (search_count ties the row pull β€” truncation guard);
(2) the local open-qty arithmetic ties Odoo's own aggregates: for the pulled order set,
Ξ£(product_uom_qty) βˆ’ Ξ£(qty_delivered) via server-side read_group == Ξ£ of our per-row
open_qty (same universe, independent arithmetic path)."""
b = board(team_id, t)
n_count = O.get_odoo().search_count('stock.picking', list(_OPEN_PICK_DOMAIN))
checks = [{'check': 'open outgoing pickings β€” pull complete',
'a': b['n_pickings'], 'b': n_count,
'gap': b['n_pickings'] - n_count, 'ok': b['n_pickings'] == n_count}]
oids = sorted({r['order'] for r in b['rows']})
if oids:
dom = [('order_id.name', 'in', oids[:500]), ('display_type', '=', False),
('product_id', '!=', False)]
try:
g = O.read_group('sale.order.line', dom,
['product_uom_qty:sum', 'qty_delivered:sum'], [], lazy=False)
srv_open = ((g[0].get('product_uom_qty') or 0.0)
- (g[0].get('qty_delivered') or 0.0)) if g else 0.0
# NET open recomputed row-level (includes over-shipped negatives, which the board
# excludes) so both sides measure the same universe
rl = O.search_read('sale.order.line', dom, ['product_uom_qty', 'qty_delivered'])
ours = sum((l.get('product_uom_qty') or 0.0) - (l.get('qty_delivered') or 0.0)
for l in rl)
checks.append({'check': 'open qty β€” server aggregate vs row arithmetic',
'a': round(ours, 2), 'b': round(srv_open, 2),
'gap': round(ours - srv_open, 2),
'ok': abs(ours - srv_open) < 1.0})
except Exception as e:
checks.append({'check': 'open qty aggregate (read_group)', 'a': 'β€”',
'b': str(e)[:60], 'gap': '', 'ok': False})
return checks