loopable / platform /modules /health.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
30.6 kB
"""Data Health module — read-only month-end close & reconciliation checks against Odoo.
Built for the bookkeeper / accountant workflow: pick a period, clear unposted items,
reconcile AR and AP, review accruals & cut-off (unbilled revenue, goods-received-not-invoiced),
and catch errors (below-cost sales, duplicate bills, negative stock, costing gaps).
Each check returns the same shape — category / title / severity / scope / count / dollar
impact / a recommended fix / sample rows — so the page can group and render them uniformly.
Checks take a (date_from, date_to) window:
- scope 'period' → transactions dated inside the window (what happened this month)
- scope 'asof' → open balances / aging as of date_to (month-end position)
- scope 'current' → live snapshot, period-independent (e.g. on-hand stock)
READ-ONLY — nothing is ever written.
"""
import sys
import datetime as dt
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import core.odoo as O
import core.periods as P
import modules.ar as ar_mod
CATEGORIES = ['Posting & completeness', 'Accruals & cut-off', 'Receivables (AR)',
'Payables (AP)', 'Inventory & COGS', 'Data hygiene']
def _check(key, category, title, severity, scope, count, impact, impact_kind, fix,
sample=None, cols=None):
return {'key': key, 'category': category, 'title': title, 'severity': severity,
'scope': scope, 'count': count, 'impact': impact, 'impact_kind': impact_kind,
'fix': fix, 'sample': sample or [], 'cols': cols or {}}
def _days(a, b):
try:
return (dt.date.fromisoformat(str(a)[:10]) - dt.date.fromisoformat(str(b)[:10])).days
except Exception:
return 0
def _ex():
ex = O.excluded_partner_ids()
return list(ex) if ex else []
def _sum(model, dom, field):
"""sum_field guarded against empty result sets — Odoo's read_group returns None for a
sum over zero rows, which XML-RPC can't marshal. Return 0.0 instead of erroring."""
o = O.get_odoo()
return O.sum_field(model, dom, field) if o.search_count(model, dom) else 0.0
# ---- Posting & completeness -------------------------------------------------
def _draft_moves(df, dt_):
o = O.get_odoo()
dom = [('state', '=', 'draft'), ('move_type', 'in',
['out_invoice', 'in_invoice', 'out_refund', 'in_refund', 'entry']),
('date', '>=', df), ('date', '<=', dt_)]
n = o.search_count('account.move', dom)
rows = o.search_read('account.move', dom,
['name', 'move_type', 'partner_id', 'date', 'amount_total'],
limit=25, order='date desc')
TYPE = {'out_invoice': 'cust invoice', 'in_invoice': 'vendor bill',
'out_refund': 'cust credit', 'in_refund': 'vendor credit', 'entry': 'journal'}
sample = [{'entry': r.get('name') or '(draft)', 'type': TYPE.get(r['move_type'], r['move_type']),
'partner': O.m2o_name(r.get('partner_id')), 'date': str(r.get('date'))[:10],
'amount': r.get('amount_total') or 0} for r in rows]
return _check('draft', CATEGORIES[0], 'Draft / unposted entries in period',
'high' if n else 'low', 'period', n, None, None,
'Post or delete these before closing — draft invoices/bills/journals are not in the books yet.',
sample, {'amount': 'money'})
# ---- Accruals & cut-off -----------------------------------------------------
def _dni_lines(df, dt_):
"""Delivered-not-invoiced at LINE level (OCA account_cutoff_picking semantics, re-implemented):
per sale line, (qty_delivered − qty_invoiced) > 0 valued at the line's effective unit price.
Unlike order-level invoice_status (which counts whole orders and, under order-basis invoicing,
goods not yet shipped), this measures exactly the shipped-but-unbilled quantity. Domains can't
compare two fields, so candidates (qty_delivered > 0) are pulled and reconciled client-side."""
o = O.get_odoo()
dom = [('order_id.state', 'in', ['sale', 'done']), ('order_id.team_id', 'in', O.TEAM_IDS),
('qty_delivered', '>', 0),
('order_id.date_order', '>=', f'{df} 00:00:00'),
('order_id.date_order', '<=', f'{dt_} 23:59:59')]
if _ex():
dom.append(('order_partner_id', 'not in', _ex()))
lines = o.search_read('sale.order.line', dom,
['order_id', 'order_partner_id', 'qty_delivered', 'qty_invoiced',
'product_uom_qty', 'price_unit', 'price_subtotal'])
out = []
for r in lines:
gap = (r.get('qty_delivered') or 0) - (r.get('qty_invoiced') or 0)
if gap <= 1e-3:
continue
qty = r.get('product_uom_qty') or 0
unit = (r['price_subtotal'] / qty) if qty else (r.get('price_unit') or 0)
r['_dni_qty'] = gap
r['_dni_val'] = gap * unit
out.append(r)
return lines, out
def _unbilled_revenue(df, dt_):
lines, dni = _dni_lines(df, dt_)
total = sum(r['_dni_val'] for r in dni)
by_order = {}
for r in dni:
onm = O.m2o_name(r['order_id'])
e = by_order.setdefault(onm, {'order': onm, 'customer': O.m2o_name(r['order_partner_id']),
'unbilled': 0.0})
e['unbilled'] += r['_dni_val']
sample = sorted(by_order.values(), key=lambda x: -x['unbilled'])[:25]
return _check('unbilled', CATEGORIES[1], 'Delivered but not invoiced (revenue to go bill)',
'high' if total > 25000 else 'medium', 'period', len(by_order), total, 'dollar',
'Shipped quantity exceeds invoiced quantity on these orders — bill the gap now '
'(or accrue it at cut-off). Line-level: partial invoices are netted correctly.',
sample, {'unbilled': 'money'})
def _grni(df, dt_):
"""Goods received not invoiced — accrue the unbilled vendor cost at close."""
o = O.get_odoo()
lines = o.search_read('purchase.order.line',
[('order_id.invoice_status', '=', 'to invoice'), ('qty_received', '>', 0)],
['qty_received', 'qty_invoiced', 'price_unit', 'product_id', 'order_id'])
by = {}
total = 0.0
for r in lines:
d = (r.get('qty_received') or 0) - (r.get('qty_invoiced') or 0)
if d <= 0:
continue
val = d * (r.get('price_unit') or 0)
total += val
po = O.m2o_name(r.get('order_id'))
e = by.setdefault(po, {'po': po, 'accrual': 0.0})
e['accrual'] += val
rows = sorted(by.values(), key=lambda x: -x['accrual'])[:25]
return _check('grni', CATEGORIES[1], 'Goods received, not yet invoiced (GRNI accrual)',
'high' if total > 25000 else 'medium', 'current', len(by), total, 'dollar',
'Accrue this vendor cost at period close (Dr inventory/COGS, Cr GRNI) until the bills arrive.',
rows, {'accrual': 'money'})
# ---- Receivables (AR) -------------------------------------------------------
def _ancient_ar(df, dt_):
o = O.get_odoo()
cutoff = (dt.date.fromisoformat(dt_) - dt.timedelta(days=365)).isoformat()
dom = [('move_type', '=', 'out_invoice'), ('state', '=', 'posted'),
('payment_state', 'in', ['not_paid', 'partial']), ('invoice_date_due', '<', cutoff)]
if _ex():
dom.append(('partner_id', 'not in', _ex()))
n = o.search_count('account.move', dom)
amt = _sum('account.move', dom, 'amount_residual_signed')
rows = o.search_read('account.move', dom,
['name', 'partner_id', 'invoice_date_due', 'amount_residual_signed'],
limit=25, order='invoice_date_due asc')
sample = [{'invoice': r['name'], 'customer': O.m2o_name(r['partner_id']),
'days_overdue': _days(dt_, r['invoice_date_due']),
'open': r.get('amount_residual_signed') or 0} for r in rows]
return _check('ancient_ar', CATEGORIES[2], 'Invoices 365+ days overdue (write-off candidates)',
'high' if amt > 25000 else 'medium', 'asof', n, amt, 'dollar',
'Decide: escalate, settle, or write off. Provision the doubtful portion at close.',
sample, {'open': 'money', 'days_overdue': ('int', 'days overdue')})
def _ar_recon(df, dt_):
rows = ar_mod.reconciliation_flags(limit=25)
allr = ar_mod.reconciliation_flags(limit=10 ** 9)
amt = sum(abs(r['gap']) for r in allr)
return _check('ar_recon', CATEGORIES[2], 'AR sub-ledger vs partner balance mismatches',
'medium', 'current', len(allr), amt, 'dollar',
'Reconcile unapplied payments/credits so the AR sub-ledger ties to the partner balance.',
rows, {'odoo_receivable': 'money', 'open_docs': 'money', 'gap': 'money'})
def _stale_ar(df, dt_):
o = O.get_odoo()
cutoff = (dt.date.fromisoformat(dt_) - dt.timedelta(days=180)).isoformat()
g = o.read_group('sale.order',
[('state', 'in', ['sale', 'done']), ('team_id', 'in', O.TEAM_IDS),
('date_order', '>=', f'{cutoff} 00:00:00'), ('date_order', '<=', f'{dt_} 23:59:59')],
['partner_id'], ['partner_id'], lazy=False)
active = {O.m2o_id(r['partner_id']) for r in g if r.get('partner_id')}
parts = o.search_read('res.partner', [('credit', '>', 500)] +
([('id', 'not in', _ex())] if _ex() else []), ['name', 'credit'])
stale = [p for p in parts if p['id'] not in active]
amt = sum(p['credit'] for p in stale)
sample = sorted([{'customer': p['name'], 'owes': p['credit']} for p in stale],
key=lambda x: -x['owes'])[:25]
return _check('stale_ar', CATEGORIES[2], 'Customers owing money but quiet 180+ days',
'high' if amt > 25000 else 'medium', 'asof', len(stale), amt, 'dollar',
'Push collection and a win-back; provision if uncollectible.', sample, {'owes': 'money'})
# ---- Payables (AP) ----------------------------------------------------------
def _ap_overdue(df, dt_):
o = O.get_odoo()
dom = [('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
('payment_state', 'in', ['not_paid', 'partial']), ('invoice_date_due', '<', dt_)]
n = o.search_count('account.move', dom)
amt = abs(_sum('account.move', dom, 'amount_residual_signed'))
rows = o.search_read('account.move', dom,
['name', 'partner_id', 'invoice_date_due', 'amount_residual_signed', 'ref'],
limit=25, order='invoice_date_due asc')
sample = [{'bill': r.get('ref') or r['name'], 'vendor': O.m2o_name(r['partner_id']),
'days_overdue': _days(dt_, r['invoice_date_due']),
'open': abs(r.get('amount_residual_signed') or 0)} for r in rows]
return _check('ap_overdue', CATEGORIES[3], 'Vendor bills overdue (as of period end)',
'medium', 'asof', n, amt, 'dollar',
'Schedule/clear overdue payables; confirm none are duplicates before paying.',
sample, {'open': 'money', 'days_overdue': ('int', 'days overdue')})
def _duplicate_bills(df, dt_):
o = O.get_odoo()
dom = [('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
('invoice_date', '>=', df), ('invoice_date', '<=', dt_), ('ref', '!=', False)]
rows = o.search_read('account.move', dom, ['name', 'partner_id', 'ref', 'amount_total', 'invoice_date'])
seen = {}
for r in rows:
k = (O.m2o_id(r.get('partner_id')), str(r.get('ref')).strip().lower(), round(r.get('amount_total') or 0, 2))
seen.setdefault(k, []).append(r)
dups = [v for v in seen.values() if len(v) > 1]
sample = sorted([{'vendor': O.m2o_name(v[0]['partner_id']), 'ref': v[0]['ref'],
'amount': v[0]['amount_total'] or 0, 'copies': len(v)} for v in dups],
key=lambda x: -x['amount'])[:25]
amt = sum((len(v) - 1) * (v[0]['amount_total'] or 0) for v in dups)
return _check('dup_bills', CATEGORIES[3], 'Possible duplicate vendor bills (same vendor/ref/amount)',
'high' if dups else 'low', 'period', len(dups), amt, 'dollar',
'Review before paying — duplicate bills cause double payment.', sample, {'amount': 'money'})
# ---- Inventory & COGS -------------------------------------------------------
def _negative_margin(df, dt_):
o = O.get_odoo()
dom = O.sale_line_domain(df, dt_, extra=[('margin', '<', 0)])
n = o.search_count('sale.order.line', dom)
loss = _sum('sale.order.line', dom, 'margin')
g = o.read_group('sale.order.line', dom, ['margin:sum', 'product_id'], ['product_id'], lazy=False)
rows = sorted([{'product': O.m2o_name(r['product_id']), 'margin_lost': r.get('margin') or 0}
for r in g if r.get('product_id')], key=lambda x: x['margin_lost'])[:25]
return _check('neg_margin', CATEGORIES[4], 'Sales below cost (negative margin) in period',
'high' if loss < -5000 else 'medium', 'period', n, loss, 'dollar',
'Reprice or stop selling these SKUs; check for costing errors driving false losses.',
rows, {'margin_lost': 'money'})
def _negative_stock(df, dt_):
o = O.get_odoo()
dom = [('location_id.usage', '=', 'internal'), ('quantity', '<', 0)]
n = o.search_count('stock.quant', dom)
rows = o.search_read('stock.quant', dom, ['product_id', 'quantity'], limit=25, order='quantity asc')
sample = [{'product': O.m2o_name(r['product_id']), 'on_hand': r['quantity']} for r in rows]
return _check('neg_stock', CATEGORIES[4], 'Negative on-hand stock (impossible quantities)',
'high' if n > 20 else 'medium', 'current', n, None, None,
'Fix receipts/adjustments — negative on-hand distorts inventory valuation and COGS.',
sample, {'on_hand': 'num'})
def _product_master(df, dt_):
o = O.get_odoo()
prods = o.search_read('product.product', [('active', '=', True), ('default_code', '!=', False)],
['id', 'default_code', 'name', 'standard_price', 'type', 'sale_ok', 'categ_id'])
q = o.read_group('stock.quant', [('location_id.usage', '=', 'internal')],
['product_id', 'quantity:sum'], ['product_id'], lazy=False)
onhand = {O.m2o_id(r['product_id']): (r.get('quantity') or 0.0) for r in q if r.get('product_id')}
roots = {c['id'] for c in o.search_read('product.category', [('parent_id', '=', False)], ['id'])}
uncosted, dup, uncat = [], {}, []
for p in prods:
oh = onhand.get(p['id'], 0.0)
if p.get('type') == 'product' and oh > 0 and (p.get('standard_price') or 0) <= 0:
uncosted.append({'sku': p['default_code'], 'product': p['name'], 'on_hand': oh})
dup.setdefault(str(p['default_code']).strip(), []).append(p['name'])
if p.get('sale_ok') and O.m2o_id(p.get('categ_id')) in roots:
uncat.append({'sku': p['default_code'], 'product': p['name']})
dups = [{'sku': k, 'records': len(v)} for k, v in dup.items() if len(v) > 1]
c_unc = _check('uncosted', CATEGORIES[4], 'In-stock SKUs with zero cost (valuation gap)',
'medium', 'current', len(uncosted), None, None,
'Set standard cost — these read as $0 inventory and distort margin and valuation.',
sorted(uncosted, key=lambda x: -x['on_hand'])[:25], {'on_hand': 'num'})
c_dup = _check('dup_codes', CATEGORIES[5], 'Duplicate active SKU codes', 'medium', 'current',
len(dups), None, None, 'Merge/retire duplicates — shared codes double-count.',
sorted(dups, key=lambda x: -x['records'])[:25], {'records': 'int'})
c_cat = _check('uncat', CATEGORIES[5], 'Sellable products with no real category', 'low', 'current',
len(uncat), None, None, 'Assign a category so by-category reporting works.', uncat[:25], {})
return [c_unc, c_dup, c_cat]
def _orders_no_rep(df, dt_):
o = O.get_odoo()
dom = [('state', 'in', ['sale', 'done']), ('team_id', 'in', O.TEAM_IDS), ('user_id', '=', False),
('date_order', '>=', f'{df} 00:00:00'), ('date_order', '<=', f'{dt_} 23:59:59')]
n = o.search_count('sale.order', dom)
amt = _sum('sale.order', dom, 'amount_untaxed')
rows = o.search_read('sale.order', dom, ['name', 'partner_id', 'amount_untaxed'],
limit=25, order='amount_untaxed desc')
sample = [{'order': r['name'], 'customer': O.m2o_name(r['partner_id']), 'amount': r['amount_untaxed']}
for r in rows]
return _check('no_rep', CATEGORIES[5], 'Orders with no salesperson assigned', 'low', 'period',
n, amt, 'dollar', 'Assign a salesperson for correct attribution/commissions.',
sample, {'amount': 'money'})
# ---- orchestration ----------------------------------------------------------
def _overdue_activities(df, dt_):
"""Scheduled activities (mail.activity) past their deadline — at probe time ALL 811 open
activities were overdue: the activity system is dead-lettered, so nothing scheduled there
can be trusted as a reminder."""
o = O.get_odoo()
today = P.today().isoformat()
n_open = o.search_count('mail.activity', [])
over = O.search_read('mail.activity', [('date_deadline', '<', today)],
['user_id', 'date_deadline', 'res_model', 'summary'])
by_user = {}
for a in over:
u = O.m2o_name(a.get('user_id')) or '(unassigned)'
e = by_user.setdefault(u, {'user': u, 'overdue': 0, 'oldest': today})
e['overdue'] += 1
d = str(a.get('date_deadline') or today)[:10]
if d < e['oldest']:
e['oldest'] = d
sample = sorted(by_user.values(), key=lambda x: -x['overdue'])[:15]
return _check('overdue_activities', CATEGORIES[5], 'Scheduled activities past deadline',
'medium' if len(over) < 50 else 'high', 'current', len(over), None, 'count',
f'{len(over)} of {n_open} open activities are overdue - clear or delete them; '
'a reminder system where everything is late reminds nobody of anything.',
sample, {'overdue': 'int'})
def _partner_tag_hygiene(df, dt_):
"""Partner tags duplicate the BU concept (Fisch/Royal tags vs team_id) — flag duplicate tag
names and tag-vs-team mismatches (a Royal-tagged customer on the Fisch team)."""
o = O.get_odoo()
tags = O.search_read('res.partner.category', [], ['name'])
names = {}
for t in tags:
names.setdefault((t['name'] or '').strip().upper(), []).append(t['id'])
dups = {k: v for k, v in names.items() if len(v) > 1}
issues = [{'issue': f'duplicate tag name "{k}" ({len(v)} tags)', 'count': len(v)} for k, v in dups.items()]
mism = 0
sample_m = []
for tag_name, team in (('FISCH', 6), ('ROYAL', 5)): # tag says one BU, team says the OTHER
ids = [i for k, v in names.items() if k == tag_name for i in v]
if ids:
rows = O.search_read('res.partner',
[('category_id', 'in', ids), ('team_id', '=', team)],
['name'], limit=10)
n = o.search_count('res.partner', [('category_id', 'in', ids), ('team_id', '=', team)])
mism += n
sample_m += [{'issue': f'tagged {tag_name.title()} but on the other BU team',
'partner': r['name']} for r in rows[:5]]
total = sum(i['count'] for i in issues) + mism
return _check('tag_hygiene', CATEGORIES[5], 'Partner tag hygiene (duplicates / BU mismatch)',
'low', 'current', total, None, 'count',
'Merge duplicate tags; align Fisch/Royal tags with the sales team (tags feed '
'segmentation - a mismatch silently mis-buckets the customer).',
issues + sample_m, {})
def _no_terms_invoices(df, dt_):
"""Posted customer invoices with NO payment terms — due date defaults silently and dunning
logic has nothing to anchor on."""
o = O.get_odoo()
dom = [('move_type', '=', 'out_invoice'), ('state', '=', 'posted'),
('invoice_payment_term_id', '=', False),
('invoice_date', '>=', f'{df}'), ('invoice_date', '<=', f'{dt_}')]
if _ex():
dom.append(('partner_id', 'not in', _ex()))
n = o.search_count('account.move', dom)
amt = _sum('account.move', dom, 'amount_total')
rows = O.search_read('account.move', dom, ['name', 'partner_id', 'invoice_date', 'amount_total'],
limit=15, order='amount_total desc')
sample = [{'invoice': r['name'], 'customer': O.m2o_name(r['partner_id']),
'date': str(r['invoice_date'])[:10], 'amount': r['amount_total']} for r in rows]
return _check('no_terms', CATEGORIES[2], 'Invoices posted without payment terms',
'low' if amt < 25000 else 'medium', 'period', n, amt, 'dollar',
'Set a default payment term on these customers - no terms means the due date '
'and any dunning cadence are meaningless for them.',
sample, {'amount': 'money'})
# Economic-nexus (Wayfair) SALES thresholds by state, 2026 — transaction-count tests are mostly
# repealed so only the revenue test is monitored. None = no state sales tax (NH/OR/MT/DE).
# NY is $500k AND 100 sales; AK is local-option (monitored at $100k). Source: Avalara/TaxJar
# state guides — VERIFY WITH THE CPA before registering anywhere; this is a radar, not advice.
NEXUS_THRESHOLDS = {
'AL': 250000, 'AK': 100000, 'AZ': 100000, 'AR': 100000, 'CA': 500000, 'CO': 100000,
'CT': 100000, 'DE': None, 'FL': 100000, 'GA': 100000, 'HI': 100000, 'ID': 100000,
'IL': 100000, 'IN': 100000, 'IA': 100000, 'KS': 100000, 'KY': 100000, 'LA': 100000,
'ME': 100000, 'MD': 100000, 'MA': 100000, 'MI': 100000, 'MN': 100000, 'MS': 250000,
'MO': 100000, 'MT': None, 'NE': 100000, 'NV': 100000, 'NH': None, 'NJ': 100000,
'NM': 100000, 'NY': 500000, 'NC': 100000, 'ND': 100000, 'OH': 100000, 'OK': 100000,
'OR': None, 'PA': 100000, 'RI': 100000, 'SC': 100000, 'SD': 100000, 'TN': 100000,
'TX': 500000, 'UT': 100000, 'VT': 100000, 'VA': 100000, 'WA': 100000, 'WV': 100000,
'WI': 100000, 'WY': 100000, 'DC': 100000}
def nexus(t=None):
"""Economic-nexus radar: trailing-12m invoiced revenue (posted invoices − credit notes) by
SHIP-TO state vs each state's Wayfair threshold. The company collects ZERO sales tax (all
resale-exempt) — crossing a threshold unnoticed creates back-liability. Trailing 12m is a
PROXY (states legally measure current/previous calendar year); status: OVER / >75% warming /
monitoring. Home state carries physical nexus regardless."""
t = t or P.today()
d12 = (t - dt.timedelta(days=365)).isoformat()
def _by_ship(mt, sign):
out = {}
for g in O.read_group('account.move',
[('move_type', '=', mt), ('state', '=', 'posted'),
('invoice_date', '>=', d12)],
['amount_untaxed:sum'], ['partner_shipping_id'], lazy=False):
pid = O.m2o_id(g.get('partner_shipping_id'))
if pid:
e = out.setdefault(pid, [0.0, 0])
e[0] += sign * (g.get('amount_untaxed') or 0.0)
e[1] += g.get('__count') or 0
return out
inv = _by_ship('out_invoice', 1)
for pid, (v, n) in _by_ship('out_refund', -1).items():
e = inv.setdefault(pid, [0.0, 0])
e[0] += v # refunds reduce state revenue; their doc count isn't a 'sale'
pids = list(inv.keys())
pstate = {}
for i in range(0, len(pids), 5000):
for p in O.search_read('res.partner', [('id', 'in', pids[i:i + 5000])],
['state_id', 'country_id']):
code = None
if p.get('state_id'):
# state m2o name is the full name; pull the code from the state record below
code = O.m2o_id(p['state_id'])
pstate[p['id']] = {'state_rid': code,
'country': O.m2o_name(p.get('country_id')) or ''}
srids = list({v['state_rid'] for v in pstate.values() if v['state_rid']})
scode = {}
for i in range(0, len(srids), 5000):
for s in O.search_read('res.country.state', [('id', 'in', srids[i:i + 5000])],
['code', 'country_id']):
scode[s['id']] = {'code': s.get('code'), 'country': O.m2o_name(s.get('country_id'))}
per, unmapped_val, unmapped_n = {}, 0.0, 0
for pid, (val, n) in inv.items():
ps = pstate.get(pid) or {}
sc = scode.get(ps.get('state_rid')) or {}
code, ctry = sc.get('code'), (sc.get('country') or ps.get('country') or '')
if code and ('United States' in ctry or ctry == ''):
e = per.setdefault(code, {'state': code, 'revenue_12m': 0.0, 'n_invoices': 0})
e['revenue_12m'] += val
e['n_invoices'] += n
else:
unmapped_val += val
unmapped_n += n
rows = []
for e in per.values():
th = NEXUS_THRESHOLDS.get(e['state'])
e['threshold'] = th
e['pct_of_threshold'] = (e['revenue_12m'] / th * 100) if th else None
e['status'] = ('no sales tax' if th is None
else 'OVER' if e['revenue_12m'] >= th
else 'warming' if e['revenue_12m'] >= th * 0.75
else 'monitor')
rows.append(e)
rows.sort(key=lambda x: -(x['pct_of_threshold'] or 0))
total = sum(e['revenue_12m'] for e in per.values()) + unmapped_val
return {'rows': rows, 'unmapped_value': unmapped_val, 'unmapped_n': unmapped_n,
'n_over': sum(1 for r in rows if r['status'] == 'OVER'),
'n_warming': sum(1 for r in rows if r['status'] == 'warming'),
'_total_built': total, '_d12': d12}
def nexus_validate(nx):
"""Σ(state revenue) + unmapped == server net invoiced (partition, two aggregation paths)."""
d12 = nx['_d12']
srv = (O.sum_field('account.move',
[('move_type', '=', 'out_invoice'), ('state', '=', 'posted'),
('invoice_date', '>=', d12)], 'amount_untaxed')
- O.sum_field('account.move',
[('move_type', '=', 'out_refund'), ('state', '=', 'posted'),
('invoice_date', '>=', d12)], 'amount_untaxed'))
return [{'check': 'Nexus: Σ(state revenue) + unmapped == server net invoiced (12m)',
'a': round(nx['_total_built'], 2), 'b': round(srv, 2),
'gap': round(nx['_total_built'] - srv, 2),
'ok': abs(nx['_total_built'] - srv) <= max(1.0, abs(srv) * 0.001)}]
def _window(date_from=None, date_to=None, t=None):
t = t or P.today()
if date_from and date_to:
return date_from, date_to
return P.ytd(t) # default = year to date
def run_all(date_from=None, date_to=None, t=None):
df, dt_ = _window(date_from, date_to, t)
checks = [_draft_moves(df, dt_), _unbilled_revenue(df, dt_), _grni(df, dt_),
_ancient_ar(df, dt_), _ar_recon(df, dt_), _stale_ar(df, dt_),
_ap_overdue(df, dt_), _duplicate_bills(df, dt_),
_negative_margin(df, dt_), _negative_stock(df, dt_), _orders_no_rep(df, dt_),
_overdue_activities(df, dt_), _partner_tag_hygiene(df, dt_),
_no_terms_invoices(df, dt_)]
checks += _product_master(df, dt_)
sev = {'high': 0, 'medium': 1, 'low': 2}
return sorted([c for c in checks if c],
key=lambda c: (sev.get(c['severity'], 9), -abs(c['impact'] or 0)))
def by_category(date_from=None, date_to=None, t=None):
checks = run_all(date_from, date_to, t)
return {cat: [c for c in checks if c['category'] == cat] for cat in CATEGORIES}
def summarize(checks):
"""Build the summary KPIs from an already-computed checks list (no extra Odoo calls)."""
flagged = [c for c in checks if c['count'] > 0]
return {
'total_checks': len(checks),
'issues': len(flagged),
'high': sum(1 for c in flagged if c['severity'] == 'high'),
'dollar_at_stake': sum(abs(c['impact']) for c in flagged if c['impact_kind'] == 'dollar'),
'clean': len(checks) - len(flagged),
}
def summary(date_from=None, date_to=None, t=None):
return summarize(run_all(date_from, date_to, t))
def validate(date_from=None, date_to=None, t=None):
"""Light, independent reconciliations (no full run_all): aggregate vs row-by-row."""
df, dt_ = _window(date_from, date_to, t)
o = O.get_odoo()
out = []
# DNI: row-by-row delivered/invoiced quantity sums vs independent read_group aggregates
# over the SAME candidate domain (two aggregation paths must agree).
dom = [('order_id.state', 'in', ['sale', 'done']), ('order_id.team_id', 'in', O.TEAM_IDS),
('qty_delivered', '>', 0),
('order_id.date_order', '>=', f'{df} 00:00:00'),
('order_id.date_order', '<=', f'{dt_} 23:59:59')]
if _ex():
dom.append(('order_partner_id', 'not in', _ex()))
lines, _ = _dni_lines(df, dt_)
row_qd = sum(r.get('qty_delivered') or 0 for r in lines)
row_qi = sum(r.get('qty_invoiced') or 0 for r in lines)
agg_qd = _sum('sale.order.line', dom, 'qty_delivered')
agg_qi = _sum('sale.order.line', dom, 'qty_invoiced')
out.append({'check': 'DNI: row Σ qty_delivered == read_group Σ (candidate lines, period)',
'a': round(row_qd, 2), 'b': round(agg_qd, 2), 'gap': round(row_qd - agg_qd, 2),
'ok': abs(row_qd - agg_qd) <= 0.01})
out.append({'check': 'DNI: row Σ qty_invoiced == read_group Σ (candidate lines, period)',
'a': round(row_qi, 2), 'b': round(agg_qi, 2), 'gap': round(row_qi - agg_qi, 2),
'ok': abs(row_qi - agg_qi) <= 0.01})
sdom = [('location_id.usage', '=', 'internal'), ('quantity', '<', 0)]
n_neg = o.search_count('stock.quant', sdom)
ids = o.search_read('stock.quant', sdom, ['id'])
out.append({'check': 'Negative-stock: search_count == len(search_read)',
'a': n_neg, 'b': len(ids), 'gap': n_neg - len(ids), 'ok': n_neg == len(ids)})
return out