| """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) |
| 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}) |
|
|
| |
| 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}) |
|
|
| |
| 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 |
|
|