File size: 10,775 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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""modules/management.py — Management Income Statements (FFS-M / RI-M / GD-M / BU-M),
reproduced from RAW ODOO for the Sep-2024 cutover onward.

Domain logic only (pure: data in -> plain dicts out). The heavy lifting is in
core/management_pl.py (live clean-entity pull + `-M = clean + overlay`); this module shapes it
for the page and provides validate(). See MODEL_LOGIC_MAP.md §10.

Company-level financial reporting with its own entity + month picker (the management basis spans
Fisch / Royal / Giftware + a consolidation — not the 2-team Fisch/Royal DBA brand), so it is an HQ
module that ignores the global brand selector.
"""
import json
import os

from core import management_pl as MP
from core import odoo as O

ENTITIES = [('FFS', 'Fisch (FFS)'), ('RI', 'Royal (RI)'), ('GD', 'Giftware (GD)'), ('BU', 'Consolidated')]
ENTITY_LABEL = dict(ENTITIES)
_EXCL_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'mgmt_exclusions.json')
_SECTION_ORDER = ['income', 'other_income', 'cogs', 'expense', 'deprec', '']
_SECTION_LABEL = {'income': 'Income', 'other_income': 'Other income', 'cogs': 'Cost of revenue',
                  'expense': 'Operating expense', 'deprec': 'Depreciation', '': ''}


def months_available():
    """Months we can reproduce -M for = the months present in the extracted overlay
    (Sep-2024 .. last reconciled month in the model). Ascending list of (year, month)."""
    out = []
    for ym in sorted(MP._overlay().keys()):
        y, m = ym.split('-')
        out.append((int(y), int(m)))
    return out


def summary_kpis(year, month, entity):
    s = MP.statement(year, month, entity)
    rev = s['income']
    return {'income': rev, 'gross_profit': s['gross_profit'], 'net_profit': s['net_profit'],
            'gross_margin': (s['gross_profit'] / rev * 100) if rev else 0.0,
            'net_margin': (s['net_profit'] / rev * 100) if rev else 0.0}


def statement(year, month, entity):
    """Section subtotals (income / cogs / gross / opex / deprec / net) for one entity-month."""
    return MP.statement(year, month, entity)


def line_rows(year, month, entity):
    """Account-level rows for the statement table: {section,code,account,amount}, P&L-ordered."""
    s = MP.statement(year, month, entity)
    name_by_code = {a['code']: a['name'] for a in MP.account_master().values()}
    rows = [{'section': _SECTION_LABEL.get(MP._bucket(code) or '', ''),
             'code': code, 'account': name_by_code.get(code, ''), 'amount': round(amt, 2)}
            for code, amt in s['lines'].items()]
    order = {lbl: i for i, lbl in enumerate(_SECTION_LABEL[k] for k in _SECTION_ORDER)}
    rows.sort(key=lambda r: (order.get(r['section'], 9), str(r['code'])))
    return rows


def _exclusions():
    """{entity: [{code,name,flag}]} — GL codes excluded from each entity's analytics (from the
    model's -M tabs, via build_mgmt_overlay.py). flag=True = a real misclassification to flag."""
    try:
        with open(_EXCL_PATH, encoding='utf-8') as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def _odoo_base():
    return os.environ.get('ODOO_URL', '').rstrip('/')


def _move_links(gid, ana, start, end):
    """Deep-links to the Odoo journal entries behind a misclassified balance, so the bookkeeper can
    open each and re-tag its analytic. Read-only: these are just URLs into Odoo's own UI."""
    base = _odoo_base()
    if not base:
        return []
    lines = O.search_read('account.analytic.line',
                          [('date', '>=', start), ('date', '<=', end),
                           ('general_account_id', '=', gid), ('account_id', '=', ana)], ['move_line_id'])
    mlids = [l['move_line_id'][0] for l in lines if l.get('move_line_id')]
    moves = []
    if mlids:
        mls = O.search_read('account.move.line', [('id', 'in', mlids)], ['move_id'])
        moves = sorted({ml['move_id'][0] for ml in mls if ml.get('move_id')})
    return [f"{base}/web#id={mid}&model=account.move&view_type=form" for mid in moves]


def classification_flags(year=None, month=None):
    """Health check: any *flaggable* excluded GL code with a nonzero balance in its entity's analytic
    is a misclassification (e.g. an Amazon cost booked to Fisch, or a mortgage to a BU instead of HQ).
    EBMS / inventory adjustments are excluded by design and never flagged. Each flag carries the
    offending amount + click-to-fix Odoo journal-entry links. Defaults to the latest month."""
    if year is None or month is None:
        av = months_available()
        if not av:
            return []
        year, month = av[-1]
    excl = _exclusions()
    start, end = MP.month_bounds(year, month)
    code_id = {a['code']: gid for gid, a in MP.account_master().items()}
    flags = []
    for entity, items in excl.items():
        ana = MP.ENTITY_ANALYTIC.get(entity)
        if ana is None:
            continue
        for it in items:
            if not it.get('flag'):
                continue
            gid = code_id.get(it['code'])
            if not gid:
                continue
            bal = O.sum_field('account.analytic.line',
                              [('date', '>=', start), ('date', '<=', end),
                               ('general_account_id', '=', gid), ('account_id', '=', ana)], 'amount')
            if abs(bal) > 0.005:
                flags.append({'entity': entity, 'entity_label': ENTITY_LABEL.get(entity, entity),
                              'code': it['code'], 'account': it['name'], 'amount': round(bal, 2),
                              'moves': _move_links(gid, ana, start, end)})
    return flags


def _recent_months(n, anchor=None):
    """The last `n` (year, month) tuples ending at `anchor` (default = latest reproducible month,
    else the current month)."""
    import datetime
    if anchor is None:
        av = months_available()
        anchor = av[-1] if av else (datetime.date.today().year, datetime.date.today().month)
    y, m = anchor
    out = []
    for _ in range(n):
        out.append((y, m)); m -= 1
        if m == 0:
            y, m = y - 1, 12
    return list(reversed(out))


def account_detail(code, months=13):
    """Canonical financial-account view (consolidated, brand-independent): one GL account's monthly
    trend split by business unit (clean analytic basis, management display sign) + recent posted
    journal lines + an Odoo deep-link. Same data wherever the account is opened from. None if unknown."""
    am = MP.account_master()
    gid = next((i for i, a in am.items() if a['code'] == code), None)
    if gid is None:
        return None
    acc = am[gid]
    section = _SECTION_LABEL.get(MP._bucket(code) or '', '') or (acc.get('type') or '')
    yms = _recent_months(months)
    ent_keys = [('FFS', 'Fisch'), ('RI', 'Royal'), ('GD', 'GD'), ('HQ', 'HQ')]
    trend = []
    for (y, m) in yms:
        ce = MP.clean_entities(y, m)
        row = {'month': f'{y}-{m:02d}'}
        tot = 0.0
        for k, _lbl in ent_keys:
            v = ce.get(k, {}).get(code, 0.0)
            row[k] = round(v, 2); tot += v
        tot += ce.get('Internal', {}).get(code, 0.0)
        row['total'] = round(tot, 2)
        trend.append(row)

    base = _odoo_base()
    jl = O.search_read('account.move.line', [('account_id', '=', gid), ('parent_state', '=', 'posted')],
                       ['date', 'move_id', 'partner_id', 'name', 'balance'], order='date desc', limit=30)
    lines = []
    for l in jl:
        mid = l['move_id'][0] if l.get('move_id') else None
        lines.append({'date': l.get('date') or '', 'entry': O.m2o_name(l.get('move_id')),
                      'partner': O.m2o_name(l.get('partner_id')) or '', 'label': l.get('name') or '',
                      'amount': round(l.get('balance') or 0.0, 2),
                      'link': (f"{base}/web#id={mid}&model=account.move&view_type=form" if base and mid else None)})
    return {'code': code, 'name': acc.get('name', ''), 'type': acc.get('type'), 'section': section,
            'window': f"{yms[0][0]}-{yms[0][1]:02d} -> {yms[-1][0]}-{yms[-1][1]:02d}",
            'window_total': round(sum(r['total'] for r in trend), 2),
            'odoo_link': (f"{base}/web#id={gid}&model=account.account&view_type=form" if base else None),
            'trend': trend, 'lines': lines}


def validate(year=None, month=None):
    """Reconcile the reproduced consolidated -M to INDEPENDENT Odoo aggregates (works on the Space,
    no workbook): each headline = a separate read_group sum straight from Odoo. Defaults to the
    latest reproducible month. Returns [{check,a,b,gap,ok}] per the platform module contract."""
    if year is None or month is None:
        avail = months_available()
        if not avail:
            return [{'check': 'overlay present (data/mgmt_overlay.json)', 'a': 0, 'b': 1, 'gap': -1, 'ok': False}]
        year, month = avail[-1]
    checks = []
    start, end = MP.month_bounds(year, month)
    clean = MP.clean_entities(year, month)
    am = MP.account_master()
    code_id = {a['code']: gid for gid, a in am.items()}
    bu_ana = [MP.ENTITY_ANALYTIC[b] for b in MP.BUS]

    for code, label in (('51000', 'Product Sales'), ('600000', 'Product Cost')):
        gid = code_id.get(code)
        indep = 0.0
        if gid:
            g = O.read_group('account.analytic.line',
                             domain=[('date', '>=', start), ('date', '<=', end),
                                     ('general_account_id', '=', gid), ('account_id', 'in', bu_ana)],
                             fields=['amount:sum'], groupby=[], lazy=False)
            indep = abs((g[0].get('amount') or 0.0) if g else 0.0)  # display positive
        repro = sum(clean.get(b, {}).get(code, 0.0) for b in MP.BUS)
        gap = round(repro - indep, 2)
        checks.append({'check': f'{code} {label} — consolidated clean vs direct Odoo sum',
                       'a': round(repro, 2), 'b': round(indep, 2), 'gap': gap, 'ok': abs(gap) < 0.01})

    # consolidation consistency: BU net == FFS + RI + GD net
    bu_net = MP.statement(year, month, 'BU')['net_profit']
    parts = sum(MP.statement(year, month, b)['net_profit'] for b in MP.BUS)
    gap = round(bu_net - parts, 2)
    checks.append({'check': 'BU-M net profit == FFS-M + RI-M + GD-M',
                   'a': round(bu_net, 2), 'b': round(parts, 2), 'gap': gap, 'ok': abs(gap) < 0.01})

    # classification health: no excluded GL code should post to the wrong entity's analytic
    nflag = len(classification_flags(year, month))
    checks.append({'check': 'No GL misclassifications (excluded codes in wrong business unit)',
                   'a': nflag, 'b': 0, 'gap': nflag, 'ok': nflag == 0})
    return checks