loopable / platform /modules /backorders.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
79cd247 verified
Raw
History Blame
11.8 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 sku_backlog(t=None, team_id=None):
"""`(pre_book_by_code, open_backlog_by_code, report)` for the product catalogue.
This is deliberately NOT `board()`'s ``not due`` bucket. That board enters through open
pickings and substitutes a picking scheduled date when an order has no commitment date;
pre-book means the stricter, stored-order fact ``commitment_date > end of t``. Both measures
are confirmed wholesale goods that Odoo still calls pending, started, or partial. Their
quantity is the positive part of ordered minus delivered, grouped by product and then by the
catalogue's code.
"""
t = t or P.today()
if isinstance(t, dt.datetime):
t = t.date()
end_of_day = f"{t.isoformat()} 23:59:59"
teams = [team_id] if team_id is not None else O.active_team_ids()
common = [
('state', 'in', ['sale', 'done']),
('product_id', '!=', False),
('order_id.delivery_status', 'in', ['pending', 'started', 'partial']),
('product_id.type', '!=', 'service'),
]
if teams:
common.append(('order_id.team_id', 'in', teams))
excluded = O.excluded_partner_ids()
if excluded:
common.append(('order_partner_id', 'not in', list(excluded)))
def _by_product(domain):
groups = O.read_group('sale.order.line', domain,
['product_uom_qty:sum', 'qty_delivered:sum'], ['product_id'],
lazy=False)
out = {}
for group in groups:
pid = O.m2o_id(group.get('product_id'))
if not pid:
continue
open_qty = max(0.0, float(group.get('product_uom_qty') or 0.0)
- float(group.get('qty_delivered') or 0.0))
if open_qty > 1e-9:
out[pid] = open_qty
return out
open_by_pid = _by_product(list(common))
pre_by_pid = _by_product(list(common) + [('order_id.commitment_date', '>', end_of_day)])
pids = sorted(set(open_by_pid) | set(pre_by_pid))
codes = {}
for ch in _chunk(pids, 500):
for product in O.search_read('product.product',
[('id', 'in', ch), ('active', 'in', [True, False])],
['default_code']):
pid = product.get('id')
codes[pid] = str(product.get('default_code') or f"pid:{pid}").strip()
def _by_code(by_pid):
out = {}
for pid, qty in by_pid.items():
code = codes.get(pid, f"pid:{pid}")
out[code] = out.get(code, 0.0) + qty
return out
pre_by_code, open_by_code = _by_code(pre_by_pid), _by_code(open_by_pid)
report = {
'as_of': t.isoformat(),
'team_ids': teams,
'pre_book_qty': round(sum(pre_by_code.values()), 2),
'pre_book_skus': len(pre_by_code),
'open_backlog_qty': round(sum(open_by_code.values()), 2),
'open_backlog_skus': len(open_by_code),
}
return pre_by_code, open_by_code, report
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