File size: 26,152 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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
"""Procurement module — biweekly buy list + the full Qualify-tab workflow, rebuilt from the Royal
Imports Inventory System (v24/v25).

Master data (Supplier / Country / Lead / CBM / container / first cost) comes from the curated map
`procurement_suppliers.json` (~3,960 SKUs from the Inventory System Mastersheet). The team's live
edits (change vendor/country/cost) are overlaid in the UI from a `procurement_overrides` store, so a
re-vendored SKU regroups instantly without re-pulling Odoo. Alternative vendors + prices come from
Odoo `product.supplierinfo` (the cheapest-vendor view). Demand / on-hand / incoming POs are live
from Odoo (read-only).

Reorder math (validated on the v24.4 Backend):
  8-month demand = units sold same 8-month window last year (all channels)
  avg/day        = 8M demand / window days
  days of cover  = (on-hand + incoming PO) / avg/day
  reorder point  = lead-time days * avg/day
  Reorder QTY    = ceil(reorder point * 1.05)                       [Baseline]
  Reorder +trend = Reorder QTY * (YTD units this yr / last yr)
  BUY TODAY      = days of cover < lead time
READ-ONLY on Odoo; the team's Order Qty + master-data edits persist in the platform store.
"""
import sys
import json
import math
import calendar
import datetime as dt
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from functools import lru_cache
import core.odoo as O
import core.periods as P

_HERE = Path(__file__).resolve().parent
_SAFETY = 0.05
_PROJ_MONTHS = 8
_NO_SVC = [('product_id.type', '!=', 'service')]


@lru_cache(maxsize=1)
def suppliers():
    """SKU(default_code) -> {desc, unit, qty_per_unit, first_cost, cbm, max_container, vendor,
    country, lead} from the curated map."""
    f = _HERE.parent / 'procurement_suppliers.json'
    try:
        return json.loads(f.read_text(encoding='utf-8'))
    except Exception:
        return {}


def _add_months(d, n):
    m = d.month - 1 + n
    y = d.year + m // 12
    m = m % 12 + 1
    return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))


def _proj_window(t):
    return t, _add_months(t, _PROJ_MONTHS)


def _demand_domain(a, b):
    return [('order_id.state', 'in', ('sale', 'done')), ('order_id.date_order', '>=', a),
            ('order_id.date_order', '<=', b)] + _NO_SVC


def _units(a, b):
    g = O.read_group('sale.order.line', _demand_domain(a, b), ['product_uom_qty:sum'],
                     ['product_id'], lazy=False)
    return {O.m2o_id(r['product_id']): (r.get('product_uom_qty') or 0.0)
            for r in g if r.get('product_id')}


def _incoming_po():
    out = {}
    for r in O.read_group('purchase.order.line', [('state', '=', 'purchase')],
                          ['product_qty:sum', 'qty_received:sum'], ['product_id'], lazy=False):
        pid = O.m2o_id(r.get('product_id'))
        q = (r.get('product_qty') or 0.0) - (r.get('qty_received') or 0.0)
        if pid and q > 0:
            out[pid] = q
    return out


def _bought_12m(cutoff_iso):
    """Units PURCHASED from suppliers in the last 12 months (confirmed POs), per product — the
    reference for 'how much we actually bought last year' next to the projected need."""
    out = {}
    for r in O.read_group('purchase.order.line',
                          [('order_id.state', 'in', ('purchase', 'done')),
                           ('order_id.date_order', '>=', cutoff_iso)],
                          ['product_qty:sum'], ['product_id'], lazy=False):
        pid = O.m2o_id(r.get('product_id'))
        if pid:
            out[pid] = r.get('product_qty') or 0.0
    return out


def _supplierinfo_by_tmpl():
    """{template_id: [{'vendor','vendor_id','price'}...]} — Odoo's vendors per product, for the
    multiple-vendor / cheapest-price views (Odoo prices are populated; lead times are not)."""
    out = {}
    for s in O.search_read('product.supplierinfo', [], ['product_tmpl_id', 'partner_id', 'price'],
                           limit=100000):
        t = O.m2o_id(s.get('product_tmpl_id'))
        if not t:
            continue
        out.setdefault(t, []).append({'vendor': O.m2o_name(s.get('partner_id')),
                                      'vendor_id': O.m2o_id(s.get('partner_id')),
                                      'price': s.get('price') or 0.0})
    return out


def _preorders(cutoff_iso):
    """Per-product open customer demand (undelivered units on confirmed orders) + earliest need-by
    date. STORED fields only — `qty_to_deliver` is a computed field and filtering/aggregating it forces
    Odoo to compute across the whole line population (8+ min); instead sum the stored
    `product_uom_qty - qty_delivered`. Bounded to orders since `cutoff_iso` so ancient never-closed
    backorders don't dominate the figures. Returns ({pid: qty}, {pid: 'YYYY-MM-DD'})."""
    dom = [('order_id.state', '=', 'sale'), ('order_id.date_order', '>=', cutoff_iso)]
    qty = {}
    for r in O.read_group('sale.order.line', dom, ['product_uom_qty:sum', 'qty_delivered:sum'],
                          ['product_id'], lazy=False):
        pid = O.m2o_id(r.get('product_id'))
        d = (r.get('product_uom_qty') or 0.0) - (r.get('qty_delivered') or 0.0)
        if pid and d > 0:
            qty[pid] = d
    # need-by: only never-shipped lines (qty_delivered is STORED, so this filter is cheap) AND only
    # for products that actually have open qty — need_by is consumed ONLY when preorder_qty>0 (pid in
    # qty), so scoping the pull to those product_ids is both correct and much faster (~849 of ~2,870
    # products → the search drops from ~8.4s to ~5s; the other ~2,000 products' rows were pure waste).
    open_pids = list(qty)
    if not open_pids:
        return qty, {}
    lines = O.search_read('sale.order.line',
                          dom + [('qty_delivered', '=', 0), ('product_id', 'in', open_pids)],
                          ['product_id', 'order_id'], limit=400000)
    odate = {}
    oids = list({O.m2o_id(l['order_id']) for l in lines if l.get('order_id')})
    for o in O.search_read('sale.order', [('id', 'in', oids)], ['commitment_date', 'date_order']):
        d = o.get('commitment_date') or o.get('date_order')
        if d:
            odate[o['id']] = str(d)[:10]
    need = {}
    for l in lines:
        pid, oid = O.m2o_id(l['product_id']), O.m2o_id(l['order_id'])
        d = odate.get(oid)
        if pid and d and (pid not in need or d < need[pid]):
            need[pid] = d
    return qty, need


def recommendations(t=None):
    """Base per-SKU table (curated master data + live Odoo demand/stock/PO + alt-vendors). The UI
    overlays the team's vendor/country/cost edits on top; reorder methods + CBM/container are here."""
    t = t or P.today()
    sup = suppliers()
    if not sup:
        return []
    codeset = set(sup.keys())
    ps, pe = _proj_window(t)
    window_days = max(1, (pe - ps).days)
    d8a, d8b = _add_months(t, -12).isoformat(), _add_months(pe, -12).isoformat()
    yf, yt = P.ytd(t)
    lf, lt = P.ytd_last_year(t)
    prods, demand8, ytd_now, ytd_prev, inpo, si, preorders, bought = O.parallel([
        lambda: O.search_read('product.product',
                              [('default_code', '!=', False), ('active', 'in', [True, False])],
                              ['default_code', 'name', 'qty_available', 'standard_price', 'product_tmpl_id']),
        lambda: _units(d8a, d8b),
        lambda: _units(yf, yt),
        lambda: _units(lf, lt),
        _incoming_po,
        _supplierinfo_by_tmpl,
        lambda: _preorders(d8a),
        lambda: _bought_12m(d8a),
    ])
    pre_qty, pre_need = preorders
    today_iso = t.isoformat()
    by_code = {}
    for p in prods:
        c = (p.get('default_code') or '').strip()
        if c and c in codeset and c not in by_code:
            by_code[c] = p
    if not by_code:
        return []

    rows = []
    for code, p in by_code.items():
        pid = p['id']
        meta = sup.get(code, {})
        lead = meta.get('lead') or 0
        tmpl = O.m2o_id(p.get('product_tmpl_id'))
        alts = si.get(tmpl, [])
        priced = sorted((a for a in alts if (a.get('price') or 0) > 0), key=lambda a: a['price'])
        cheapest = priced[0] if priced else None
        cost = (meta.get('first_cost') or (cheapest['price'] if cheapest else None)
                or p.get('standard_price') or 0.0)
        d8 = demand8.get(pid, 0.0)
        avg_day = d8 / window_days if window_days else 0.0
        on_hand = p.get('qty_available') or 0.0
        po = inpo.get(pid, 0.0)
        avail = on_hand + po
        days_cover = (avail / avg_day) if avg_day > 0 else (None if avail <= 0 else 9999.0)
        reorder_pt = lead * avg_day
        rbase = math.ceil(reorder_pt * (1 + _SAFETY)) if reorder_pt > 0 else 0
        yn, yp = ytd_now.get(pid, 0.0), ytd_prev.get(pid, 0.0)
        trend = (yn / yp) if yp > 0 else None
        rtrend = int(round(rbase * trend)) if trend is not None else rbase
        buy = (days_cover is not None and lead > 0 and avg_day > 0 and days_cover < lead)
        rows.append({
            'code': code, 'product': meta.get('desc') or p.get('name') or code,
            'unit': meta.get('unit') or '', 'qty_per_unit': meta.get('qty_per_unit'),
            'vendor': meta.get('vendor') or '(no supplier)', 'country': meta.get('country') or '',
            'lead': lead, 'on_hand': on_hand, 'incoming_po': po, 'demand_8m': d8, 'avg_day': avg_day,
            'bought_12m': bought.get(pid, 0.0),
            'days_cover': days_cover, 'reorder_baseline': rbase, 'reorder_trend': rtrend,
            'trend_pct': ((trend - 1) * 100) if trend is not None else None,
            'ytd_units': yn, 'ytd_units_ly': yp, 'unit_cost': cost, 'buy': buy,
            'cbm': meta.get('cbm'), 'max_container': meta.get('max_container'),
            'preorder_qty': pre_qty.get(pid, 0.0),
            'need_by': pre_need.get(pid) if pre_qty.get(pid, 0) > 0 else None,
            'overdue': bool(pre_qty.get(pid, 0) > 0 and pre_need.get(pid) and pre_need.get(pid) < today_iso),
            'email': meta.get('email'), 'city': meta.get('city'), 'street': meta.get('street'),
            'port': meta.get('port'),
            'n_vendors': len({a['vendor_id'] for a in alts if a.get('vendor_id')}),
            'cheapest_vendor': cheapest['vendor'] if cheapest else None,
            'cheapest_price': cheapest['price'] if cheapest else None,
            'alt_vendors': priced,
        })
    return rows


def purchase_need_key(r):
    """Sort key = 'what we need to purchase', most urgent first. SKUs flagged BUY (past the
    reorder point) come before OK SKUs; within each, lower days-of-cover (closer to stocking out)
    ranks higher; a bigger suggested reorder breaks ties. No-demand / fully-covered SKUs
    (days_cover None or 9999) sink to the bottom. This is what orders the per-vendor line list so
    that in 'All SKUs' mode the actionable SKUs float to the top while the whole catalogue stays
    visible below."""
    dc = r.get('days_cover')
    if dc is None or dc >= 9999:
        dc = float('inf')
    return (0 if r.get('buy') else 1, dc, -(r.get('reorder_trend') or 0))


def by_supplier(recs):
    """Group rows into per-vendor purchase orders (pure — call with overrides already applied).
    Each vendor's lines are ordered by purchase need (buy-first, most urgent on top — see
    purchase_need_key), so the buy list and the 'All SKUs per vendor' view surface what to order
    without hunting; the full SKU set for the vendor is still present, just sorted underneath."""
    pos = {}
    for r in recs:
        v = r['vendor']
        po = pos.setdefault(v, {'vendor': v, 'country': r.get('country', ''), 'lead': r.get('lead', 0),
                               'lines': [], 'skus': 0})
        po['lines'].append(r)
        po['skus'] += 1
    for po in pos.values():
        po['lines'].sort(key=purchase_need_key)
    return sorted(pos.values(), key=lambda x: (x['vendor'] or '~'))


def data_quality():
    sup = suppliers()
    codes = list(sup.keys())
    cs = set(codes)
    found = {c for c in ((p.get('default_code') or '').strip()
                         for p in O.search_read('product.product',
                                                [('default_code', '!=', False), ('active', 'in', [True, False])],
                                                ['default_code'])) if c in cs}
    missing = [c for c in codes if c not in found]
    no_vendor = [c for c, d in sup.items() if not d.get('vendor')]
    no_lead = [c for c, d in sup.items() if not d.get('lead')]
    return [
        {'issue': 'Curated SKU not found in Odoo (no live data)', 'count': len(missing),
         'fix': 'Check the SKU internal reference matches Odoo, or remove it from the mapping.',
         'sample': [{'default_code': c, 'name': sup.get(c, {}).get('desc')} for c in missing]},
        {'issue': 'No supplier in the mapping', 'count': len(no_vendor),
         'fix': 'Set the vendor on the SKU (Procurement page or Vendor module) so a PO can be grouped.',
         'sample': [{'default_code': c, 'name': sup.get(c, {}).get('desc')} for c in no_vendor]},
        {'issue': 'No lead time in the mapping', 'count': len(no_lead),
         'fix': 'Set the supplier lead time (days) — the buy trigger needs it.',
         'sample': [{'default_code': c, 'name': sup.get(c, {}).get('desc')} for c in no_lead]},
    ]


def _open_po_lines():
    """All confirmed-PO lines still (partly) undelivered: state='purchase', open qty > 0, with the
    line's expected date. One pull powers the PO board, the per-SKU inbound match and the late
    chase list. (Same open-qty semantics as _incoming_po — the two are cross-validated.)"""
    lines = O.search_read('purchase.order.line',
                          [('state', '=', 'purchase'), ('product_qty', '>', 0)],
                          ['order_id', 'partner_id', 'product_id', 'product_qty', 'qty_received',
                           'price_unit', 'price_subtotal', 'date_planned'])
    out = []
    for r in lines:
        open_qty = (r.get('product_qty') or 0) - (r.get('qty_received') or 0)
        if open_qty <= 1e-3:
            continue
        qty = r.get('product_qty') or 0
        unit = (r['price_subtotal'] / qty) if qty else (r.get('price_unit') or 0)
        r['open_qty'] = open_qty
        r['open_value'] = open_qty * unit
        r['expected'] = str(r.get('date_planned') or '')[:10]
        out.append(r)
    return lines, out


def open_pos(t=None):
    """The open-PO board matched to procurement: order-level list of every confirmed PO with
    undelivered quantity, per-SKU inbound (open qty + earliest ETA + late portion) keyed by
    default_code so it joins the buy list, and the late-delivery chase list."""
    t = t or P.today()
    today = t.isoformat()
    _all, lines = _open_po_lines()

    pids = list({O.m2o_id(l['product_id']) for l in lines if l.get('product_id')})
    codes = {}
    for i in range(0, len(pids), 5000):
        for p in O.search_read('product.product',
                               [('id', 'in', pids[i:i + 5000]), ('active', 'in', [True, False])],
                               ['default_code']):
            codes[p['id']] = (p.get('default_code') or '').strip()

    oids = list({O.m2o_id(l['order_id']) for l in lines if l.get('order_id')})
    odates = {}
    for i in range(0, len(oids), 5000):
        for o_ in O.search_read('purchase.order', [('id', 'in', oids[i:i + 5000])],
                                ['date_order', 'amount_total']):
            odates[o_['id']] = {'date_order': str(o_.get('date_order') or '')[:10],
                                'amount_total': o_.get('amount_total') or 0.0}

    by_po, by_sku, late_rows = {}, {}, []
    for l in lines:
        po_id = O.m2o_id(l['order_id'])
        po_nm = O.m2o_name(l['order_id'])
        sup = O.m2o_name(l['partner_id'])
        pid = O.m2o_id(l['product_id'])
        code = codes.get(pid) or O.m2o_name(l['product_id'])
        exp = l['expected']
        is_late = bool(exp and exp < today)

        e = by_po.setdefault(po_nm, {
            'po': po_nm, 'supplier': sup,
            'ordered': odates.get(po_id, {}).get('date_order', ''),
            'po_value': odates.get(po_id, {}).get('amount_total', 0.0),
            'expected': exp, 'open_lines': 0, 'open_value': 0.0, 'late_value': 0.0})
        e['open_lines'] += 1
        e['open_value'] += l['open_value']
        if exp and (not e['expected'] or exp < e['expected']):
            e['expected'] = exp
        if is_late:
            e['late_value'] += l['open_value']

        s = by_sku.setdefault(code, {'open_qty': 0.0, 'open_value': 0.0, 'eta': None,
                                     'late_qty': 0.0, 'pos': set()})
        s['open_qty'] += l['open_qty']
        s['open_value'] += l['open_value']
        s['pos'].add(po_nm)
        if exp and (s['eta'] is None or exp < s['eta']):
            s['eta'] = exp
        if is_late:
            s['late_qty'] += l['open_qty']

        if is_late:
            days_late = (t - dt.date.fromisoformat(exp)).days
            late_rows.append({'supplier': sup, 'po': po_nm, 'sku': code,
                              'product': O.m2o_name(l['product_id']),
                              'open_qty': l['open_qty'], 'open_value': l['open_value'],
                              'expected': exp, 'days_late': days_late})

    for s in by_sku.values():
        s['pos'] = sorted(s['pos'])
    pos = sorted(by_po.values(), key=lambda x: (x['expected'] or '9999'))
    late_rows.sort(key=lambda x: -x['days_late'])
    return {
        'pos': pos, 'by_sku': by_sku, 'late': late_rows,
        'n_pos': len(pos), 'open_value': sum(p['open_value'] for p in pos),
        'late_value': sum(p['late_value'] for p in pos), 'n_late_lines': len(late_rows),
    }


def _pctl(sorted_vals, q):
    return sorted_vals[min(len(sorted_vals) - 1, int(len(sorted_vals) * q))] if sorted_vals else None


def vendor_reliability(t=None):
    """Vendor delivery truth from COMPLETED POs (24m): actual lead (date_order → effective_date)
    and promise lateness (date_planned → effective_date), per vendor. purchase.order carries all
    three dates, so no picking join is needed (the 'incoming picking' route is polluted by
    customer returns — verified live: only 29% of incoming pickings match a PO).
    The buy trigger runs on the CURATED nominal lead; this measures how wrong that is per vendor."""
    t = t or P.today()
    cutoff = (t - dt.timedelta(days=730)).isoformat()
    pos_ = O.search_read('purchase.order',
                         [('state', 'in', ('purchase', 'done')), ('date_order', '>=', cutoff),
                          ('effective_date', '!=', False)],
                         ['name', 'partner_id', 'date_order', 'date_planned', 'effective_date',
                          'amount_total'])
    # nominal lead per vendor = the most common curated per-SKU lead for that vendor (upper-name join)
    nom = {}
    for d in suppliers().values():
        v = (d.get('vendor') or '').strip().upper()
        if v and d.get('lead'):
            nom.setdefault(v, {})
            nom[v][d['lead']] = nom[v].get(d['lead'], 0) + 1
    nominal = {v: max(c.items(), key=lambda kv: kv[1])[0] for v, c in nom.items()}

    rows, per = [], {}
    for p in pos_:
        v = (O.m2o_name(p.get('partner_id')) or '(unknown)').strip()
        o = dt.date.fromisoformat(str(p['date_order'])[:10])
        e = dt.date.fromisoformat(str(p['effective_date'])[:10])
        lead = (e - o).days
        late = None
        if p.get('date_planned'):
            late = (e - dt.date.fromisoformat(str(p['date_planned'])[:10])).days
        rows.append({'po': p['name'], 'vendor': v, 'ordered': o.isoformat(),
                     'promised': str(p.get('date_planned') or '')[:10] or None,
                     'received': e.isoformat(), 'lead_days': lead, 'days_late': late,
                     'po_value': p.get('amount_total') or 0.0})
        s = per.setdefault(v, {'vendor': v, 'n_pos': 0, 'value': 0.0, 'leads': [], 'lates': []})
        s['n_pos'] += 1
        s['value'] += p.get('amount_total') or 0.0
        s['leads'].append(lead)
        if late is not None:
            s['lates'].append(late)

    vendors = []
    for s in per.values():
        s['leads'].sort()
        s['lates'].sort()
        vu = s['vendor'].upper()
        v_nom = nominal.get(vu)
        p90 = _pctl(s['leads'], 0.9)
        vendors.append({'vendor': s['vendor'], 'n_pos': s['n_pos'], 'value': s['value'],
                        'lead_med': _pctl(s['leads'], 0.5), 'lead_p90': p90,
                        'nominal_lead': v_nom,
                        'lead_gap': (p90 - v_nom) if (v_nom and p90 is not None) else None,
                        'on_promise_pct': (sum(1 for d in s['lates'] if d <= 0) / len(s['lates']) * 100)
                                          if s['lates'] else None,
                        'late_med': _pctl(s['lates'], 0.5), 'late_p90': _pctl(s['lates'], 0.9)})
    vendors.sort(key=lambda x: -x['value'])
    all_lates = sorted(r['days_late'] for r in rows if r['days_late'] is not None)
    return {'vendors': vendors, 'rows': rows, 'n_pos': len(rows),
            'n_late': sum(1 for d in all_lates if d > 0),
            'late_share_pct': (sum(1 for d in all_lates if d > 0) / len(all_lates) * 100)
                              if all_lates else None,
            'late_med': _pctl(all_lates, 0.5), 'late_p90': _pctl(all_lates, 0.9),
            'n_vendors': len(vendors),
            'actual_p90_by_vendor': {v['vendor'].upper(): v['lead_p90'] for v in vendors
                                     if v['n_pos'] >= 3 and v['lead_p90'] is not None}}


def buy_under_actual_lead(recs, vr):
    """SKUs the nominal-lead trigger says are SAFE but that are already past the reorder point
    under their vendor's ACTUAL p90 lead (vendors with 3+ completed POs). The honest delta —
    the base buy list is untouched."""
    p90 = vr.get('actual_p90_by_vendor') or {}
    out = []
    for r in recs:
        if r.get('buy'):
            continue
        ap = p90.get((r.get('vendor') or '').strip().upper())
        if ap is None or r.get('days_cover') is None or r['days_cover'] >= 9999:
            continue
        eff = max(ap, r.get('lead') or 0)
        if r.get('avg_day', 0) > 0 and r['days_cover'] < eff:
            out.append({**r, 'actual_lead_p90': ap})
    out.sort(key=lambda x: (x['days_cover'] if x['days_cover'] is not None else 9999))
    return out


def summary(t=None):
    t = t or P.today()
    recs = recommendations(t)
    ps, pe = _proj_window(t)
    vr = vendor_reliability(t)
    return {
        'recs': recs, 'n_skus': len(recs),
        'window_days': max(1, (pe - ps).days), 'safety_pct': _SAFETY * 100,
        'inbound': open_pos(t),
        'data_quality': data_quality(),
        'vendor_reliability': vr,
        'buy_flip': buy_under_actual_lead(recs, vr),
        'pulled_at': dt.datetime.now().strftime('%Y-%m-%d %H:%M'),
    }


def vendor_book(t=None):
    """Per-vendor rollup for the Vendor module: each vendor's SKU count, lead, country, demand value,
    and the SKUs they supply. Plus the multiple-vendor / cheapest-price view."""
    recs = recommendations(t)
    multi = [r for r in recs if (r.get('n_vendors') or 0) > 1]
    return {'recs': recs, 'multi_vendor': multi, 'n_multi': len(multi)}


def validate(t=None, team_id=None):
    t = t or P.today()
    _, pe = _proj_window(t)
    f, to = _add_months(t, -12).isoformat(), _add_months(pe, -12).isoformat()
    g = O.read_group('sale.order.line', _demand_domain(f, to), ['product_uom_qty:sum'],
                     ['product_id'], lazy=False)
    per = sum((r.get('product_uom_qty') or 0.0) for r in g if r.get('product_id'))
    tot = O.sum_field('sale.order.line', _demand_domain(f, to), 'product_uom_qty')
    checks = [{'check': '8-month demand: Σ(per-SKU units) == line-level units',
               'a': round(per, 2), 'b': round(tot, 2), 'gap': round(per - tot, 2),
               'ok': abs(per - tot) <= 1.0}]

    # Open POs: row-by-row NET (product_qty − qty_received over ALL confirmed lines — the same
    # semantics as _incoming_po, over-receipts included) vs the _incoming_po read_group aggregate.
    # Two aggregation paths, small live-drift tolerance (two RPC snapshots on a live ledger).
    ib = open_pos(t)
    all_lines, _ = _open_po_lines()
    row_net = sum((l.get('product_qty') or 0) - (l.get('qty_received') or 0) for l in all_lines)
    agg_net = sum(_incoming_po().values())
    checks.append({'check': 'Open POs: row-level Σ net qty == read_group Σ net qty (live-drift tol)',
                   'a': round(row_net, 2), 'b': round(agg_net, 2),
                   'gap': round(row_net - agg_net, 2),
                   'ok': abs(row_net - agg_net) <= max(1.0, abs(agg_net) * 0.001)})

    # Internal consistency: the late chase list is exactly the late portion of the SKU rollup.
    late_val = sum(r['open_value'] for r in ib['late'])
    sku_late_val_ok = sum(p['late_value'] for p in ib['pos'])
    checks.append({'check': 'Open POs: Σ(late chase lines) == Σ(per-PO late value)',
                   'a': round(late_val, 2), 'b': round(sku_late_val_ok, 2),
                   'gap': round(late_val - sku_late_val_ok, 2),
                   'ok': abs(late_val - sku_late_val_ok) <= 1.0})

    # Vendor reliability: the per-vendor rollup partitions the PO rows exactly (counts AND value).
    vr = vendor_reliability(t)
    vn = sum(v['n_pos'] for v in vr['vendors'])
    checks.append({'check': 'Vendor reliability: Σ(per-vendor POs) == PO rows pulled',
                   'a': vn, 'b': vr['n_pos'], 'gap': vn - vr['n_pos'],
                   'ok': vn == vr['n_pos']})
    vv = sum(v['value'] for v in vr['vendors'])
    rv = sum(r['po_value'] for r in vr['rows'])
    checks.append({'check': 'Vendor reliability: Σ(per-vendor PO value) == Σ(row PO value)',
                   'a': round(vv, 2), 'b': round(rv, 2), 'gap': round(vv - rv, 2),
                   'ok': abs(vv - rv) <= 1.0})
    return checks