"""modules/management.py — Management Income Statements (FFS-M / RI-M / GD-M / BU-M), reproduced from RAW ODOO for the Sep-2024 cutover onward. Domain logic only (pure: data in -> plain dicts out). The heavy lifting is in core/management_pl.py (live clean-entity pull + `-M = clean + overlay`); this module shapes it for the page and provides validate(). See MODEL_LOGIC_MAP.md §10. Company-level financial reporting with its own entity + month picker (the management basis spans Fisch / Royal / Giftware + a consolidation — not the 2-team Fisch/Royal DBA brand), so it is an HQ module that ignores the global brand selector. """ import json import os from core import management_pl as MP from core import odoo as O ENTITIES = [('FFS', 'Fisch (FFS)'), ('RI', 'Royal (RI)'), ('GD', 'Giftware (GD)'), ('BU', 'Consolidated')] ENTITY_LABEL = dict(ENTITIES) _EXCL_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'mgmt_exclusions.json') _SECTION_ORDER = ['income', 'other_income', 'cogs', 'expense', 'deprec', ''] _SECTION_LABEL = {'income': 'Income', 'other_income': 'Other income', 'cogs': 'Cost of revenue', 'expense': 'Operating expense', 'deprec': 'Depreciation', '': ''} def months_available(): """Months we can reproduce -M for = the months present in the extracted overlay (Sep-2024 .. last reconciled month in the model). Ascending list of (year, month).""" out = [] for ym in sorted(MP._overlay().keys()): y, m = ym.split('-') out.append((int(y), int(m))) return out def summary_kpis(year, month, entity): s = MP.statement(year, month, entity) rev = s['income'] return {'income': rev, 'gross_profit': s['gross_profit'], 'net_profit': s['net_profit'], 'gross_margin': (s['gross_profit'] / rev * 100) if rev else 0.0, 'net_margin': (s['net_profit'] / rev * 100) if rev else 0.0} def statement(year, month, entity): """Section subtotals (income / cogs / gross / opex / deprec / net) for one entity-month.""" return MP.statement(year, month, entity) def line_rows(year, month, entity): """Account-level rows for the statement table: {section,code,account,amount}, P&L-ordered.""" s = MP.statement(year, month, entity) name_by_code = {a['code']: a['name'] for a in MP.account_master().values()} rows = [{'section': _SECTION_LABEL.get(MP._bucket(code) or '', ''), 'code': code, 'account': name_by_code.get(code, ''), 'amount': round(amt, 2)} for code, amt in s['lines'].items()] order = {lbl: i for i, lbl in enumerate(_SECTION_LABEL[k] for k in _SECTION_ORDER)} rows.sort(key=lambda r: (order.get(r['section'], 9), str(r['code']))) return rows def _exclusions(): """{entity: [{code,name,flag}]} — GL codes excluded from each entity's analytics (from the model's -M tabs, via build_mgmt_overlay.py). flag=True = a real misclassification to flag.""" try: with open(_EXCL_PATH, encoding='utf-8') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def _odoo_base(): return os.environ.get('ODOO_URL', '').rstrip('/') def _move_links(gid, ana, start, end): """Deep-links to the Odoo journal entries behind a misclassified balance, so the bookkeeper can open each and re-tag its analytic. Read-only: these are just URLs into Odoo's own UI.""" base = _odoo_base() if not base: return [] lines = O.search_read('account.analytic.line', [('date', '>=', start), ('date', '<=', end), ('general_account_id', '=', gid), ('account_id', '=', ana)], ['move_line_id']) mlids = [l['move_line_id'][0] for l in lines if l.get('move_line_id')] moves = [] if mlids: mls = O.search_read('account.move.line', [('id', 'in', mlids)], ['move_id']) moves = sorted({ml['move_id'][0] for ml in mls if ml.get('move_id')}) return [f"{base}/web#id={mid}&model=account.move&view_type=form" for mid in moves] def classification_flags(year=None, month=None): """Health check: any *flaggable* excluded GL code with a nonzero balance in its entity's analytic is a misclassification (e.g. an Amazon cost booked to Fisch, or a mortgage to a BU instead of HQ). EBMS / inventory adjustments are excluded by design and never flagged. Each flag carries the offending amount + click-to-fix Odoo journal-entry links. Defaults to the latest month.""" if year is None or month is None: av = months_available() if not av: return [] year, month = av[-1] excl = _exclusions() start, end = MP.month_bounds(year, month) code_id = {a['code']: gid for gid, a in MP.account_master().items()} flags = [] for entity, items in excl.items(): ana = MP.ENTITY_ANALYTIC.get(entity) if ana is None: continue for it in items: if not it.get('flag'): continue gid = code_id.get(it['code']) if not gid: continue bal = O.sum_field('account.analytic.line', [('date', '>=', start), ('date', '<=', end), ('general_account_id', '=', gid), ('account_id', '=', ana)], 'amount') if abs(bal) > 0.005: flags.append({'entity': entity, 'entity_label': ENTITY_LABEL.get(entity, entity), 'code': it['code'], 'account': it['name'], 'amount': round(bal, 2), 'moves': _move_links(gid, ana, start, end)}) return flags def _recent_months(n, anchor=None): """The last `n` (year, month) tuples ending at `anchor` (default = latest reproducible month, else the current month).""" import datetime if anchor is None: av = months_available() anchor = av[-1] if av else (datetime.date.today().year, datetime.date.today().month) y, m = anchor out = [] for _ in range(n): out.append((y, m)); m -= 1 if m == 0: y, m = y - 1, 12 return list(reversed(out)) def account_detail(code, months=13): """Canonical financial-account view (consolidated, brand-independent): one GL account's monthly trend split by business unit (clean analytic basis, management display sign) + recent posted journal lines + an Odoo deep-link. Same data wherever the account is opened from. None if unknown.""" am = MP.account_master() gid = next((i for i, a in am.items() if a['code'] == code), None) if gid is None: return None acc = am[gid] section = _SECTION_LABEL.get(MP._bucket(code) or '', '') or (acc.get('type') or '') yms = _recent_months(months) ent_keys = [('FFS', 'Fisch'), ('RI', 'Royal'), ('GD', 'GD'), ('HQ', 'HQ')] trend = [] for (y, m) in yms: ce = MP.clean_entities(y, m) row = {'month': f'{y}-{m:02d}'} tot = 0.0 for k, _lbl in ent_keys: v = ce.get(k, {}).get(code, 0.0) row[k] = round(v, 2); tot += v tot += ce.get('Internal', {}).get(code, 0.0) row['total'] = round(tot, 2) trend.append(row) base = _odoo_base() jl = O.search_read('account.move.line', [('account_id', '=', gid), ('parent_state', '=', 'posted')], ['date', 'move_id', 'partner_id', 'name', 'balance'], order='date desc', limit=30) lines = [] for l in jl: mid = l['move_id'][0] if l.get('move_id') else None lines.append({'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={mid}&model=account.move&view_type=form" if base and mid else None)}) return {'code': code, 'name': acc.get('name', ''), 'type': acc.get('type'), 'section': section, 'window': f"{yms[0][0]}-{yms[0][1]:02d} -> {yms[-1][0]}-{yms[-1][1]:02d}", 'window_total': round(sum(r['total'] for r in trend), 2), 'odoo_link': (f"{base}/web#id={gid}&model=account.account&view_type=form" if base else None), 'trend': trend, 'lines': lines} def validate(year=None, month=None): """Reconcile the reproduced consolidated -M to INDEPENDENT Odoo aggregates (works on the Space, no workbook): each headline = a separate read_group sum straight from Odoo. Defaults to the latest reproducible month. Returns [{check,a,b,gap,ok}] per the platform module contract.""" if year is None or month is None: avail = months_available() if not avail: return [{'check': 'overlay present (data/mgmt_overlay.json)', 'a': 0, 'b': 1, 'gap': -1, 'ok': False}] year, month = avail[-1] checks = [] start, end = MP.month_bounds(year, month) clean = MP.clean_entities(year, month) am = MP.account_master() code_id = {a['code']: gid for gid, a in am.items()} bu_ana = [MP.ENTITY_ANALYTIC[b] for b in MP.BUS] for code, label in (('51000', 'Product Sales'), ('600000', 'Product Cost')): gid = code_id.get(code) indep = 0.0 if gid: g = O.read_group('account.analytic.line', domain=[('date', '>=', start), ('date', '<=', end), ('general_account_id', '=', gid), ('account_id', 'in', bu_ana)], fields=['amount:sum'], groupby=[], lazy=False) indep = abs((g[0].get('amount') or 0.0) if g else 0.0) # display positive repro = sum(clean.get(b, {}).get(code, 0.0) for b in MP.BUS) gap = round(repro - indep, 2) checks.append({'check': f'{code} {label} — consolidated clean vs direct Odoo sum', 'a': round(repro, 2), 'b': round(indep, 2), 'gap': gap, 'ok': abs(gap) < 0.01}) # consolidation consistency: BU net == FFS + RI + GD net bu_net = MP.statement(year, month, 'BU')['net_profit'] parts = sum(MP.statement(year, month, b)['net_profit'] for b in MP.BUS) gap = round(bu_net - parts, 2) checks.append({'check': 'BU-M net profit == FFS-M + RI-M + GD-M', 'a': round(bu_net, 2), 'b': round(parts, 2), 'gap': gap, 'ok': abs(gap) < 0.01}) # classification health: no excluded GL code should post to the wrong entity's analytic nflag = len(classification_flags(year, month)) checks.append({'check': 'No GL misclassifications (excluded codes in wrong business unit)', 'a': nflag, 'b': 0, 'gap': nflag, 'ok': nflag == 0}) return checks