File size: 6,465 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
"""Order Book β€” pre-season booking coverage (the forward view sitting unused in sale.order).

Giftware pre-books: confirmed orders carry FUTURE delivery windows (commitment_date), so the
order book for the season is known months ahead. The curve compares THIS year's cumulative
booked $ for season delivery against last year's same-week curve β€” 20% under with 12 weeks to
go = cut the container program NOW; over = expand POs / push laggard agents.

Season window: deliveries Aug 1 – Dec 31 (the Q4 giftware peak; env-overridable later if the
business adds a spring season). x-axis = weeks before Oct 1 (nominal peak).
"""
import datetime as dt

import core.odoo as O
import core.periods as P

SEASON_FROM = (8, 1)      # deliveries from Aug 1
SEASON_TO = (12, 31)      # through Dec 31
PEAK = (10, 1)            # nominal peak for the weeks-before axis


def _season_orders(year, team_id=None, cutoff=None):
    """Confirmed orders with commitment_date inside `year`'s season window, booked up to
    `cutoff` (order date). Returns [(order_date, amount)]."""
    ex = O.excluded_partner_ids()
    dom = [('state', 'in', ['sale', 'done']),
           ('commitment_date', '>=', f'{year}-{SEASON_FROM[0]:02d}-{SEASON_FROM[1]:02d} 00:00:00'),
           ('commitment_date', '<=', f'{year}-{SEASON_TO[0]:02d}-{SEASON_TO[1]:02d} 23:59:59')]
    dom.append(('team_id', '=', team_id) if team_id is not None
               else ('team_id', 'in', O.TEAM_IDS))
    if cutoff:
        dom.append(('date_order', '<=', f'{cutoff} 23:59:59'))
    if ex:
        dom.append(('partner_id', 'not in', list(ex)))
    rows = O.search_read('sale.order', dom, ['date_order', 'amount_untaxed'])
    return [(str(r.get('date_order') or '')[:10], r.get('amount_untaxed') or 0.0)
            for r in rows if r.get('date_order')]


def curve(team_id=None, t=None, years_back=2):
    """Cumulative booked-$ curves, one per season year, aligned on weeks-before-peak.
    The current year's curve stops at today; prior years run to their season end."""
    t = t or P.today()
    ty = t.year
    out_rows, totals = [], {}
    for yr in range(ty - years_back, ty + 1):
        peak = dt.date(yr, *PEAK)
        cutoff = t.isoformat() if yr == ty else None
        orders = _season_orders(yr, team_id, cutoff)
        orders.sort()
        cum = 0.0
        weekly = {}
        for d, amt in orders:
            cum += amt
            wk = (peak - dt.date.fromisoformat(d)).days // 7
            weekly[wk] = cum                      # last write per week = cumulative at week end
        for wk, v in sorted(weekly.items(), reverse=True):
            out_rows.append({'year': str(yr), 'weeks_before_peak': -wk, 'booked': v})
        totals[str(yr)] = cum
    # same-week-LY comparison for the headline
    wk_now = (dt.date(ty, *PEAK) - t).days // 7
    ly_same = 0.0
    for r in out_rows:
        if r['year'] == str(ty - 1) and r['weeks_before_peak'] <= -wk_now:
            ly_same = max(ly_same, r['booked'])
    return {'rows': out_rows, 'totals': totals,
            'ty': str(ty), 'ly': str(ty - 1),
            'booked_ty': totals.get(str(ty), 0.0), 'ly_same_week': ly_same,
            'weeks_to_peak': wk_now}


def by_category(team_id=None, t=None):
    """Booked $ per product category, this season TY vs LY-as-of-the-same-date.
    (read_group cannot group by a dot-path β€” group by product, map to category locally.)"""
    t = t or P.today()
    ty = t.year
    per_prod = {}
    for yr, cut in ((ty, t.isoformat()),
                    (ty - 1, t.replace(year=ty - 1).isoformat())):
        ex = O.excluded_partner_ids()
        dom = [('order_id.state', 'in', ['sale', 'done']),
               ('order_id.commitment_date', '>=',
                f'{yr}-{SEASON_FROM[0]:02d}-{SEASON_FROM[1]:02d} 00:00:00'),
               ('order_id.commitment_date', '<=',
                f'{yr}-{SEASON_TO[0]:02d}-{SEASON_TO[1]:02d} 23:59:59'),
               ('order_id.date_order', '<=', f'{cut} 23:59:59'),
               ('product_id', '!=', False)]
        dom.append(('order_id.team_id', '=', team_id) if team_id is not None
                   else ('order_id.team_id', 'in', O.TEAM_IDS))
        if ex:
            dom.append(('order_partner_id', 'not in', list(ex)))
        for g in O.read_group('sale.order.line', dom, ['price_subtotal:sum'],
                              ['product_id'], lazy=False):
            pid = O.m2o_id(g.get('product_id'))
            if not pid:
                continue
            e = per_prod.setdefault(pid, {'ty': 0.0, 'ly': 0.0})
            e['ty' if yr == ty else 'ly'] += g.get('price_subtotal') or 0.0
    cats = {}
    pids = list(per_prod)
    for i in range(0, len(pids), 2000):
        for p in O.search_read('product.product',
                               [('id', 'in', pids[i:i + 2000]),
                                ('active', 'in', [True, False])], ['categ_id']):
            cats[p['id']] = O.m2o_name(p.get('categ_id')) or '(none)'
    out = {}
    for pid, v in per_prod.items():
        cat = cats.get(pid, '(none)')
        e = out.setdefault(cat, {'category': cat, 'ty': 0.0, 'ly': 0.0})
        e['ty'] += v['ty']
        e['ly'] += v['ly']
    rows = list(out.values())
    for r in rows:
        r['delta_pct'] = ((r['ty'] / r['ly'] - 1) * 100.0) if r['ly'] else None
    rows.sort(key=lambda x: -x['ty'])
    return rows


def validate(t=None, team_id=None):
    """The client-side cumulative end point ties one server-side aggregate over the exact
    same domain (sum_field) β€” the independent arithmetic path."""
    t = t or P.today()
    b = curve(team_id, t)
    ty = int(b['ty'])
    ex = O.excluded_partner_ids()
    dom = [('state', 'in', ['sale', 'done']),
           ('commitment_date', '>=', f'{ty}-{SEASON_FROM[0]:02d}-{SEASON_FROM[1]:02d} 00:00:00'),
           ('commitment_date', '<=', f'{ty}-{SEASON_TO[0]:02d}-{SEASON_TO[1]:02d} 23:59:59'),
           ('date_order', '<=', f'{t.isoformat()} 23:59:59')]
    dom.append(('team_id', '=', team_id) if team_id is not None
               else ('team_id', 'in', O.TEAM_IDS))
    if ex:
        dom.append(('partner_id', 'not in', list(ex)))
    srv = O.sum_field('sale.order', dom, 'amount_untaxed')
    return [{'check': f'{ty} season booked $ β€” client cumulative vs server sum',
             'a': round(b['booked_ty'], 2), 'b': round(srv, 2),
             'gap': round(b['booked_ty'] - srv, 2),
             'ok': abs(b['booked_ty'] - srv) < 1.0}]