File size: 7,097 Bytes
c14ceee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | """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}
|