"""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 <-> Odoo analytic account ids (confirmed live) -------------------------------------- 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'] # the business units that get a -M statement ENTITY_LABEL = {'FFS': 'Fisch (FFS)', 'RI': 'Royal (RI)', 'GD': 'Giftware Deals (GD)', 'BU': 'Consolidated (BU)'} # P&L sections by Odoo account_type (mirrors build_income_statements.py) 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 # ---- section roll-ups (for the statement view) -------------------------------------------------- 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}