| """core/management_pl.py — reproduce the operating model's MANAGEMENT P&Ls |
| (FFS-M / RI-M / GD-M / BU-M) monthly from RAW ODOO, Sep-2024 cutover onward. READ-ONLY. |
| |
| Decoded from v42 (see 17. Application development/financial_analysis/MODEL_LOGIC_MAP.md §10): |
| |
| -M[entity][account] = clean_entity[account] # live from Odoo (PROVEN to the cent) |
| + hq_overlay[entity][account] # HQ cost allocated to the entity |
| + adj_overrides[entity][account] # tiny, transition-month only |
| |
| clean_entity = account.analytic.line grouped by general_account_id (GL row) x account_id |
| (analytic = ENTITY column). Analytic ids: Fisch=1 Royal=2 GiftwareDeals=3 |
| HQ=4 Internal=5. Sections by account_type. This is build_income_statements.py |
| (verified exact). Proven live: Oct-2025 acct 51000 -> Fisch 327,531.85 etc. |
| |
| hq_overlay / adj_overrides come from data/mgmt_overlay.json (extracted read-only from v42 by |
| build_mgmt_overlay.py). The HQ allocation is HARDCODED in the model (PNL (HQ-*) tabs are literal |
| values, not formulas), so storing it is faithful to how the model is actually maintained. |
| |
| Consolidation: BU-M = FFS-M + RI-M + GD-M (the Internal analytic is eliminated; HQ is allocated out). |
| |
| Pure: data in -> plain dicts out. No Streamlit, no writes. |
| """ |
| from __future__ import annotations |
| import json |
| import os |
| from functools import lru_cache |
|
|
| from core import odoo as O |
|
|
| |
| ENTITY_ANALYTIC = {'FFS': 1, 'RI': 2, 'GD': 3, 'HQ': 4, 'Internal': 5} |
| ANALYTIC_ENTITY = {v: k for k, v in ENTITY_ANALYTIC.items()} |
| BUS = ['FFS', 'RI', 'GD'] |
| ENTITY_LABEL = {'FFS': 'Fisch (FFS)', 'RI': 'Royal (RI)', 'GD': 'Giftware Deals (GD)', 'BU': 'Consolidated (BU)'} |
|
|
| |
| INCOME_TYPES = {'income'} |
| OTHER_INCOME_TYPES = {'income_other'} |
| COGS_TYPES = {'expense_direct_cost'} |
| EXPENSE_TYPES = {'expense'} |
| DEPREC_TYPES = {'expense_depreciation'} |
| PL_TYPES = INCOME_TYPES | OTHER_INCOME_TYPES | COGS_TYPES | EXPENSE_TYPES | DEPREC_TYPES |
|
|
| _OVERLAY_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), |
| 'data', 'mgmt_overlay.json') |
|
|
|
|
| def month_bounds(year: int, month: int) -> tuple[str, str]: |
| import calendar |
| last = calendar.monthrange(year, month)[1] |
| return f'{year}-{month:02d}-01', f'{year}-{month:02d}-{last:02d}' |
|
|
|
|
| @lru_cache(maxsize=1) |
| def account_master() -> dict: |
| """{gl_account_id: {'code','name','type'}} for every account.""" |
| rows = O.search_read('account.account', [], ['id', 'code', 'name', 'account_type']) |
| return {r['id']: {'code': r.get('code') or '', 'name': r.get('name') or '', |
| 'type': r.get('account_type')} for r in rows} |
|
|
|
|
| def _display(acc_type: str, raw: float) -> float: |
| """Income shown raw; costs/expenses/depreciation flipped positive (model convention).""" |
| if acc_type in INCOME_TYPES or acc_type in OTHER_INCOME_TYPES: |
| return raw |
| return -raw |
|
|
|
|
| @lru_cache(maxsize=64) |
| def clean_entities(year: int, month: int) -> dict: |
| """The clean per-entity P&L straight from Odoo analytic lines, for one month. |
| |
| Returns {entity_key: {account_code: display_amount}} for FFS/RI/GD/HQ/Internal, restricted to |
| P&L account types. This is the LIVE, proven-exact foundation (= build_income_statements.py). |
| """ |
| start, end = month_bounds(year, month) |
| rows = O.read_group('account.analytic.line', |
| domain=[('date', '>=', start), ('date', '<=', end)], |
| fields=['amount:sum'], groupby=['general_account_id', 'account_id'], lazy=False) |
| am = account_master() |
| out = {k: {} for k in ENTITY_ANALYTIC} |
| for r in rows: |
| gen = r.get('general_account_id'); ana = r.get('account_id') |
| if not gen or not ana: |
| continue |
| gid, aid = gen[0], ana[0] |
| ent = ANALYTIC_ENTITY.get(aid) |
| acc = am.get(gid) |
| if ent is None or not acc or acc['type'] not in PL_TYPES: |
| continue |
| amt = _display(acc['type'], r.get('amount') or 0.0) |
| if abs(amt) < 0.005: |
| continue |
| out[ent][acc['code']] = out[ent].get(acc['code'], 0.0) + amt |
| return out |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _overlay() -> dict: |
| """The stored HQ-allocation + transition-reconciliation overlay (build_mgmt_overlay.py). |
| Shape: {'YYYY-MM': {'FFS': {acct: amt}, ...}}. These are financial figures, so they live in the |
| PRIVATE HF Dataset store on the Space (never in the public Space repo); locally they load from the |
| committed JSON. File first, so local runs need no token. Empty -> -M = clean entity only.""" |
| try: |
| with open(_OVERLAY_PATH, encoding='utf-8') as f: |
| return json.load(f) |
| except (FileNotFoundError, json.JSONDecodeError): |
| pass |
| try: |
| from core import store |
| return store.get('mgmt_overlay') or {} |
| except Exception: |
| return {} |
|
|
|
|
| def management_pl(year: int, month: int, entity: str) -> dict: |
| """The management P&L for one entity-month: {account_code: amount} = clean + overlay. |
| |
| entity in {'FFS','RI','GD'}; 'BU' returns the consolidation (sum of the three). |
| """ |
| if entity == 'BU': |
| agg: dict = {} |
| for bu in BUS: |
| for code, amt in management_pl(year, month, bu).items(): |
| agg[code] = agg.get(code, 0.0) + amt |
| return agg |
| clean = dict(clean_entities(year, month).get(entity, {})) |
| ov = _overlay().get(f'{year}-{month:02d}', {}).get(entity, {}) |
| for code, amt in ov.items(): |
| clean[code] = clean.get(code, 0.0) + amt |
| return clean |
|
|
|
|
| |
| def _bucket(code: str) -> str | None: |
| acc = next((a for a in account_master().values() if a['code'] == code), None) |
| if not acc: |
| return None |
| t = acc['type'] |
| if t in INCOME_TYPES: return 'income' |
| if t in OTHER_INCOME_TYPES: return 'other_income' |
| if t in COGS_TYPES: return 'cogs' |
| if t in EXPENSE_TYPES: return 'expense' |
| if t in DEPREC_TYPES: return 'deprec' |
| return None |
|
|
|
|
| def statement(year: int, month: int, entity: str) -> dict: |
| """Section subtotals + net for one entity-month (income / cogs / gross / opex / deprec / net).""" |
| lines = management_pl(year, month, entity) |
| sec = {'income': 0.0, 'other_income': 0.0, 'cogs': 0.0, 'expense': 0.0, 'deprec': 0.0} |
| for code, amt in lines.items(): |
| b = _bucket(code) |
| if b: |
| sec[b] += amt |
| income = sec['income'] + sec['other_income'] |
| gross = income - sec['cogs'] |
| net = gross - sec['expense'] - sec['deprec'] |
| return {'income': income, 'cogs': sec['cogs'], 'gross_profit': gross, |
| 'operating_expense': sec['expense'], 'depreciation': sec['deprec'], 'net_profit': net, |
| 'lines': lines} |
|
|