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}