loopable / platform /core /management_pl.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
7.1 kB
"""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}