File size: 19,816 Bytes
c14ceee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 | """modules/expenses.py β Expenses: the operating-expense analytics dashboard (the CFO
cost-control surface).
The company's operating cost BASE, read straight from the general ledger: every posted line to an
expense-type account (account_type in {'expense','expense_depreciation'}) over time. COGS
('expense_direct_cost') is the product side and is EXCLUDED β this is opex, not cost of sales.
Because it reads the GL directly (not just vendor bills) it also captures payroll journal entries,
depreciation and bank/merchant fees that never pass through account.move in_invoice.
This is the MONITOR / DIAGNOSE complement to the Spend & Payables WORKFLOW (which recovers
vendor-bill waste). It answers one executive question: *what is our operating cost base, how is it
trending against revenue, what grew, and where did a category spike?* It NAMES the categories to
look at; it never performs an action.
1. TREND + LEVERAGE β monthly opex and opex-as-%-of-revenue (the operating-leverage picture:
are costs growing faster than the top line?). Revenue denominator = income-type GL accounts
(all channels), so the ratio is internally consistent and independently reconcilable.
2. YoY BRIDGE β prior-LTM opex β per-category movers β current-LTM opex (what grew / fell).
3. COST SPIKES β a Wheeler XmR (individuals) control chart per category flags the months a
category ran above its own upper control limit: a diagnostic watch list, worst first.
4. FIXED VS VARIABLE β categories split by month-to-month coefficient of variation + coverage:
fixed/recurring (rent, wages, subscriptions) vs variable (where in-year cuts actually land).
5. DIRECTORY β every expense account with LTM $, share, YoY, % of revenue and type,
each drilling to the raw ledger lines behind it (no unverifiable aggregates).
Company-level: opex is shared overhead, not cleanly BU-splittable β an HQ module that ignores the
brand selector. READ-ONLY.
"""
import os
import core.odoo as O
import core.periods as P
EXP_TYPES = ('expense', 'expense_depreciation') # opex β COGS 'expense_direct_cost' excluded
INC_TYPES = ('income',) # operating revenue (the leverage denominator)
XMR_CONST = 2.66 # individuals-chart control-limit constant (Wheeler): UCL = mean + 2.66Β·mRΜ
MIN_MONTHS = 6 # minimum completed months of history before an XmR flag is trustworthy
MATERIAL_LTM = 5000.0 # ignore sub-$5k/yr accounts for spike/leverage noise (still in the directory)
FIXED_CV = 0.35 # monthly coefficient-of-variation below this + broad coverage = fixed/recurring
FIXED_COVERAGE = 9 # months (of the completed window) with activity to count as recurring
BRIDGE_TOP = 5 # named category movers in the YoY bridge (rest roll into "Other";
# kept small so the waterfall's x-axis labels stay legible)
def _odoo_base():
return os.environ.get('ODOO_URL', '').rstrip('/')
def _short_lbl(s, n=16):
s = str(s or '')
return s if len(s) <= n else s[:n - 1] + 'β¦'
# ---- data primitives: STORE-backed (OM-2 retrofit 2026-07-12) with LIVE fallback -------------
# build() reads the tenant DuckDB store (kept minutes-fresh by the app's auto-sync) β the page's
# ~30 monthly read_groups become millisecond SQL. validate() STAYS on live Odoo, so the existing
# to-the-cent contract doubles as the retrofit's standing parity proof. Any store problem
# (missing file, cold table) falls back to the live reads β the page never breaks on a cold store.
USE_STORE = True
def _store_con():
import harness.datastore as DS
return DS.ro_con() # per-thread cached read-only (shared retrofit primitive)
def _accounts_live():
rows = O.search_read('account.account',
[('account_type', 'in', list(EXP_TYPES) + list(INC_TYPES))],
['code', 'name', 'account_type'])
exp = {r['id']: {'code': r.get('code') or '', 'name': r.get('name') or ''}
for r in rows if r.get('account_type') in EXP_TYPES}
inc_ids = [r['id'] for r in rows if r.get('account_type') in INC_TYPES]
return exp, inc_ids
def _accounts_store():
types = list(EXP_TYPES) + list(INC_TYPES)
rows = _store_con().execute(
'SELECT id, code, name, account_type FROM account_account '
'WHERE account_type IN (' + ','.join('?' * len(types)) + ')', types).fetchall()
exp = {r[0]: {'code': r[1] or '', 'name': r[2] or ''} for r in rows if r[3] in EXP_TYPES}
inc_ids = [r[0] for r in rows if r[3] in INC_TYPES]
return exp, inc_ids
def _accounts():
"""{id: {code, name}} for expense-type accounts, and the list of income-account ids."""
if USE_STORE:
try:
return _accounts_store()
except Exception:
pass
return _accounts_live()
def _acct_balances_live(acc_ids, date_from, date_to):
g = O.read_group('account.move.line',
[('account_id', 'in', list(acc_ids)), ('parent_state', '=', 'posted'),
('date', '>=', date_from), ('date', '<=', date_to)],
['balance:sum'], ['account_id'], lazy=False)
return {O.m2o_id(r['account_id']): (r.get('balance') or 0.0)
for r in g if r.get('account_id')}
def _acct_balances_store(acc_ids, date_from, date_to):
ids = list(acc_ids)
rows = _store_con().execute(
'SELECT l.account_id, sum(l.balance) FROM account_move_line l '
'JOIN account_move m ON m.id = l.move_id '
"WHERE m.state = 'posted' AND l.account_id IN (" + ','.join('?' * len(ids)) + ') '
'AND CAST(l.date AS TIMESTAMP) >= ? AND CAST(l.date AS TIMESTAMP) <= ? '
'GROUP BY 1', ids + [str(date_from), str(date_to)]).fetchall()
return {r[0]: (r[1] or 0.0) for r in rows}
def _acct_balances(acc_ids, date_from, date_to):
"""{account_id: Ξ£ balance} over posted lines in the window (debit-positive: expense accounts
read positive, income accounts read negative)."""
if not acc_ids:
return {}
if USE_STORE:
try:
return _acct_balances_store(acc_ids, date_from, date_to)
except Exception:
pass
return _acct_balances_live(acc_ids, date_from, date_to)
def _income_total(inc_ids, date_from, date_to):
"""Operating revenue over the window (income accounts carry credit balances β negate)."""
if not inc_ids:
return 0.0
bal = _acct_balances(inc_ids, date_from, date_to)
return -sum(bal.values())
def _xmr(series):
"""Wheeler individuals-chart limits over a numeric series. Returns (mean, ucl, lcl, mrbar) or
None when there isn't enough history."""
xs = list(series)
if len(xs) < MIN_MONTHS:
return None
mean = sum(xs) / len(xs)
mrs = [abs(xs[i] - xs[i - 1]) for i in range(1, len(xs))]
mrbar = (sum(mrs) / len(mrs)) if mrs else 0.0
return mean, mean + XMR_CONST * mrbar, mean - XMR_CONST * mrbar, mrbar
def _cv_coverage(series):
"""(coefficient of variation, months-with-activity) over a completed monthly series."""
xs = list(series)
n = len(xs)
cov = sum(1 for x in xs if abs(x) > 0.005)
if n < 2:
return None, cov
mean = sum(xs) / n
if mean <= 0:
return None, cov
sd = (sum((x - mean) ** 2 for x in xs) / n) ** 0.5
return sd / mean, cov
def build(t=None):
"""The full Expenses bundle: scorecard inputs, monthly trend + leverage, YoY bridge steps,
the category directory, the XmR spike watch list and the fixed/variable split."""
t = t or P.today()
lf, lt = P.ltm(t)
pf, pt = P.prior_ltm(t)
exp, inc_ids = _accounts()
exp_ids = list(exp.keys())
# per-account LTM & prior-LTM opex, + LTM/prior revenue β four independent aggregates, parallel
ltm_by, prior_by, rev_ltm, rev_prior, inc_ltm_by = O.parallel([
lambda: _acct_balances(exp_ids, lf, lt),
lambda: _acct_balances(exp_ids, pf, pt),
lambda: _income_total(inc_ids, lf, lt),
lambda: _income_total(inc_ids, pf, pt),
lambda: _acct_balances(inc_ids, lf, lt),
])
opex_ltm = sum(ltm_by.values())
opex_prior = sum(prior_by.values())
# monthly matrix (24 calendar months, ascending; last row = current partial month)
months = P.month_starts(24, t)
mres = O.parallel([
(lambda mf=mf, mt=mt: (_acct_balances(exp_ids, mf, mt), _income_total(inc_ids, mf, mt)))
for (_lbl, mf, mt) in months])
cur_ym = f'{t.year}-{t.month:02d}'
monthly, month_acct = [], {aid: [] for aid in exp_ids}
for (lbl, _mf, _mt), (opx, rev) in zip(months, mres):
total = sum(opx.values())
monthly.append({'month': lbl, 'opex': total, 'revenue': rev,
'ratio': (total / rev * 100) if rev else None,
'partial': lbl == cur_ym, 'closing': False})
for aid in exp_ids:
month_acct[aid].append(opx.get(aid, 0.0))
# 3-month moving average on the opex column series (for a quiet trend line)
op_series = [m['opex'] for m in monthly]
for i, m in enumerate(monthly):
w = op_series[max(0, i - 2):i + 1]
m['ma3'] = sum(w) / len(w)
complete_idx = [i for i, (lbl, _mf, _mt) in enumerate(months) if lbl != cur_ym]
# Month-end close lags: the newest completed calendar month is often still being booked
# (vendor bills / payroll JEs / marketplace fee settlements not yet posted), which shows up as
# a near-empty month. Detect the trailing contiguous under-booked months (< CLOSE_FRAC of the
# completed-month median) and anchor month-level analysis on the last FULLY-BOOKED month, so a
# β90% "drop" that is really an open period never reads as a cost win. LTM/YoY stay rolling
# (they tie to the GL) and are captioned as-booked.
CLOSE_FRAC = 0.5
comp_all = [monthly[i]['opex'] for i in complete_idx]
_srt = sorted(comp_all)
med = _srt[len(_srt) // 2] if _srt else 0.0
closing_idx = set()
for i in reversed(complete_idx):
if med > 0 and monthly[i]['opex'] < CLOSE_FRAC * med:
closing_idx.add(i)
else:
break
for i in closing_idx:
monthly[i]['closing'] = True
closed_idx = [i for i in complete_idx if i not in closing_idx]
last_c = closed_idx[-1] if closed_idx else (complete_idx[-1] if complete_idx else None)
last_month = months[last_c][0] if last_c is not None else cur_ym
# ---- category directory + fixed/variable classification ----
rows, flags = [], []
for aid in exp_ids:
ltm_v = ltm_by.get(aid, 0.0)
pr_v = prior_by.get(aid, 0.0)
if abs(ltm_v) < 0.005 and abs(pr_v) < 0.005:
continue # never active in either window
comp = [month_acct[aid][i] for i in closed_idx]
cv, cov = _cv_coverage(comp)
ftype = ('Fixed/recurring'
if (cov >= FIXED_COVERAGE and cv is not None and cv < FIXED_CV)
else 'Variable')
rows.append({'aid': aid, 'code': exp[aid]['code'], 'account': exp[aid]['name'],
'ltm': ltm_v, 'prior': pr_v, 'yoy_d': ltm_v - pr_v,
'yoy_pct': P.yoy_pct(ltm_v, pr_v),
'pct_opex': (ltm_v / opex_ltm * 100) if opex_ltm else 0.0,
'pct_rev': (ltm_v / rev_ltm * 100) if rev_ltm else 0.0,
'type': ftype})
# ---- XmR spike flag (latest completed month above the account's own UCL) ----
stat = _xmr(comp)
if stat and last_c is not None:
mean, ucl, _lcl, mrbar = stat
latest = month_acct[aid][last_c]
if latest > ucl and mrbar > 0 and ltm_v >= MATERIAL_LTM:
flags.append({'aid': aid, 'code': exp[aid]['code'], 'account': exp[aid]['name'],
'month': last_month, 'value': latest, 'mean': mean, 'ucl': ucl,
'exceed': latest - ucl})
rows.sort(key=lambda r: -r['ltm'])
flags.sort(key=lambda x: -x['exceed'])
fixed_ltm = sum(r['ltm'] for r in rows if r['type'] == 'Fixed/recurring')
var_ltm = sum(r['ltm'] for r in rows if r['type'] == 'Variable')
# ---- income (revenue) breakdown β the denominator's verify list ----
inc_names = {}
for r in O.search_read('account.account', [('id', 'in', inc_ids)], ['name']):
inc_names[r['id']] = r.get('name') or ''
inc_rows = [{'account': inc_names.get(aid, ''), 'ltm': -bal}
for aid, bal in inc_ltm_by.items()]
inc_rows.sort(key=lambda r: -r['ltm'])
# ---- YoY bridge: prior LTM β top movers β other β current LTM ----
movers = sorted([r for r in rows if r.get('yoy_d')], key=lambda r: -abs(r['yoy_d']))
top = movers[:BRIDGE_TOP]
steps = [{'label': 'Prior LTM', 'amount': round(opex_prior, 0), 'kind': 'total'}]
used, seen = 0.0, set()
for r in top:
d = r['yoy_d']
used += d
lbl = _short_lbl(r['account']) # the waterfall x-axis is categorical β
if lbl in seen: # two similarly-named accounts must not collide
lbl = f"{_short_lbl(r['account'], 11)} {r['code']}"
seen.add(lbl)
steps.append({'label': lbl, 'amount': round(d, 0),
'kind': 'down' if d > 0 else 'up'}) # cost up = red(bad); down = green(good)
other = (opex_ltm - opex_prior) - used
if abs(other) > 1:
steps.append({'label': 'Other', 'amount': round(other, 0),
'kind': 'down' if other > 0 else 'up'})
steps.append({'label': 'Current LTM', 'amount': round(opex_ltm, 0), 'kind': 'total'})
# ---- scorecard scalars ----
opex_g = P.yoy_pct(opex_ltm, opex_prior)
rev_g = P.yoy_pct(rev_ltm, rev_prior)
opex_ratio = (opex_ltm / rev_ltm * 100) if rev_ltm else None
prior_ratio = (opex_prior / rev_prior * 100) if rev_prior else None
ratio_delta = (opex_ratio - prior_ratio) if (opex_ratio is not None and prior_ratio is not None) else None
comp_totals = [monthly[i]['opex'] for i in closed_idx]
latest_full = comp_totals[-1] if comp_totals else 0.0
trail = comp_totals[-13:-1] # the 12 closed months before the latest
trail_avg = (sum(trail) / len(trail)) if trail else 0.0
latest_vs_avg = P.yoy_pct(latest_full, trail_avg)
top5_share = (sum(r['ltm'] for r in rows[:5]) / opex_ltm * 100) if opex_ltm else 0.0
top_cat = rows[0] if rows else None
grower = max(rows, key=lambda r: (r.get('yoy_d') or -1e18)) if rows else None
n_up_fast = sum(1 for r in rows
if r.get('yoy_pct') is not None and rev_g is not None
and r['yoy_pct'] > rev_g and r['yoy_d'] > 0 and r['ltm'] > MATERIAL_LTM)
return {
'window': (lf, lt), 'last_month': last_month,
'n_closing': len(closing_idx),
'closing_months': [months[i][0] for i in sorted(closing_idx)],
'opex_ltm': opex_ltm, 'opex_prior': opex_prior, 'opex_g': opex_g,
'rev_ltm': rev_ltm, 'rev_prior': rev_prior, 'rev_g': rev_g,
'opex_ratio': opex_ratio, 'prior_ratio': prior_ratio, 'ratio_delta': ratio_delta,
'latest_full': latest_full, 'trail_avg': trail_avg, 'latest_vs_avg': latest_vs_avg,
'top5_share': top5_share, 'top_cat': top_cat, 'grower': grower, 'n_up_fast': n_up_fast,
'n_accounts': len(rows),
'monthly': monthly, 'steps': steps, 'rows': rows, 'flags': flags, 'inc_rows': inc_rows,
'fixed_ltm': fixed_ltm, 'var_ltm': var_ltm,
}
def account_detail(aid, t=None):
"""Raw-GL drill for one expense account: 15-month balance trend with its own control band,
LTM vs prior-LTM totals, the recent posted ledger lines behind it, and an Odoo deep-link.
Ties to the directory's numbers (same GL basis) β not the management-basis 'account' drawer."""
t = t or P.today()
acc = O.search_read('account.account', [('id', '=', aid)], ['code', 'name', 'account_type'])
if not acc:
return None
acc = acc[0]
months = P.month_starts(15, t)
cur_ym = f'{t.year}-{t.month:02d}'
def _m(mf, mt):
return O.sum_field('account.move.line',
[('account_id', '=', aid), ('parent_state', '=', 'posted'),
('date', '>=', mf), ('date', '<=', mt)], 'balance')
lf, lt = P.ltm(t)
pf, pt = P.prior_ltm(t)
vals = O.parallel(
[(lambda mf=mf, mt=mt: _m(mf, mt)) for (_lbl, mf, mt) in months]
+ [lambda: _m(lf, lt), lambda: _m(pf, pt)])
mvals, ltm_v, prior_v = vals[:-2], vals[-2], vals[-1]
stat = _xmr([v for (lbl, _mf, _mt), v in zip(months, mvals) if lbl != cur_ym])
mean = ucl = None
if stat:
mean, ucl = stat[0], stat[1]
trend = [{'month': lbl, 'amount': v, 'partial': lbl == cur_ym,
'over': bool(ucl is not None and v > ucl and lbl != cur_ym)}
for (lbl, _mf, _mt), v in zip(months, mvals)]
base = _odoo_base()
jl = O.search_read('account.move.line',
[('account_id', '=', aid), ('parent_state', '=', 'posted')],
['date', 'move_id', 'partner_id', 'name', 'balance'],
order='date desc', limit=40)
lines = [{'date': l.get('date') or '', 'entry': O.m2o_name(l.get('move_id')),
'partner': O.m2o_name(l.get('partner_id')) or '', 'label': l.get('name') or '',
'amount': round(l.get('balance') or 0.0, 2),
'link': (f"{base}/web#id={O.m2o_id(l['move_id'])}&model=account.move&view_type=form"
if base and l.get('move_id') else None)}
for l in jl]
return {'aid': aid, 'code': acc.get('code') or '', 'name': acc.get('name') or '',
'ltm': ltm_v, 'prior': prior_v, 'yoy_pct': P.yoy_pct(ltm_v, prior_v),
'avg_month': (ltm_v / 12.0), 'mean': mean, 'ucl': ucl,
'trend': trend, 'lines': lines,
'odoo_link': (f"{base}/web#id={aid}&model=account.account&view_type=form" if base else None)}
def validate(t=None, team_id=None, pre=None):
"""(1) client opex LTM ties an independent server Ξ£ balance over the same expense accounts;
(2) client revenue LTM ties the independent server income Ξ£; (3) the directory rows re-add to
the opex headline (nothing dropped). Company-level β team_id ignored (opex is consolidated)."""
t = t or P.today()
b = pre or build(t)
lf, lt = b['window']
exp, inc_ids = _accounts()
exp_ids = list(exp.keys())
srv_opex = O.sum_field('account.move.line',
[('account_id', 'in', exp_ids), ('parent_state', '=', 'posted'),
('date', '>=', lf), ('date', '<=', lt)], 'balance')
srv_rev = _income_total(inc_ids, lf, lt)
dir_sum = sum(r['ltm'] for r in b['rows'])
checks = [
{'check': 'operating expense LTM β client Ξ£ == server Ξ£ balance',
'a': round(b['opex_ltm'], 2), 'b': round(srv_opex, 2),
'gap': round(b['opex_ltm'] - srv_opex, 2),
'ok': abs(b['opex_ltm'] - srv_opex) <= max(1.0, abs(srv_opex) * 0.001)},
{'check': 'operating revenue LTM β client Ξ£ == server income Ξ£',
'a': round(b['rev_ltm'], 2), 'b': round(srv_rev, 2),
'gap': round(b['rev_ltm'] - srv_rev, 2),
'ok': abs(b['rev_ltm'] - srv_rev) <= max(1.0, abs(srv_rev) * 0.001)},
{'check': 'category directory Ξ£ == opex headline (nothing dropped)',
'a': round(dir_sum, 2), 'b': round(b['opex_ltm'], 2),
'gap': round(dir_sum - b['opex_ltm'], 2), 'ok': abs(dir_sum - b['opex_ltm']) < 1.0},
]
return checks
|