#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""集团维度 货号增减归因看板 验收脚本。

验收项：
 1. 渲染：外部请求=0、无 console/page error、SVG 节点>0、无横向溢出。
 2. 逐集团完整性：12 个集团块全部存在（逐个名字断言），每块含「增量」「减量」两个列表标题。
 3. 数字一致性：从 sheet『集团x货号_Δ长表』抽 ≥8 条（跨 ≥4 个集团）的
    货号/25同期/26同期/Δ 与页面文本逐条比对，两侧值都打印。
 4. 口径声明存在（含「刘子研名下数据」「不可比」）；页面不得出现 −52.7%。
 5. 截图 ≥3（总览 / 某增量集团块 / 某减量集团块）。
 6. 保护文件 mtime 未变（断言均早于本次产出 HTML 的写入时间）。

用法：
    env -u PYTHONPATH /usr/bin/python3 脚本/verify_group_attribution_board.py
"""
import os
import re
import sys

ROOT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
XLSX = os.path.join(ROOT, '销售分析', '集团维度_货号增减归因.xlsx')
HTML = os.path.join(ROOT, '工具', '集团维度_增减归因看板.html')
SHOTS = os.path.expanduser('~/.hermes/profiles/zone-b/cache/scratch/board_shots')

# 12 个集团（逐个断言）
GROUPS = ['复星集团', '东方基因集团', '杭州迪安生物技术有限公司', '苏州华益美生物科技有限公司',
          '深圳市新产业生物医学工程股份有限公司', '丽珠集团', '深圳市雅为世纪科技有限公司',
          '安瑞科集团', '中国人民解放军陆军军医大学第一附属医院', '海尔施集团',
          '天隆集团', '佰奥莱博集团']

# 保护文件（只读，不得被本次改动）
PROTECTED = [
    '工具/index.html', '工具/app.html', '工具/客户拉通大表_demo.html', '工具/仪表盘',
    '工具/天隆_2025vs2026_趋势看板.html', '工具/天隆_2025vs2026_趋势看板_v1.html', '脚本/build_tianlong_trend.py',
    '脚本/verify_tianlong_trend.py',
    '销售分析/天隆_2025vs2026_趋势看板.png',
    '销售分析/天隆_按抬头_货号统计.xlsx', '销售分析/天隆集团_2025vs2026_货号分季度同比.xlsx',
    '销售分析/天隆集团_2025vs2026_货号分季度同比_v1.xlsx',
    '销售分析/集团维度_增减归因.png', '销售分析/集团维度_货号增减归因.xlsx',
]

ok_all = True


def line(tag, msg):
    global ok_all
    if tag == '❌':
        ok_all = False
    print('%s %s' % (tag, msg))


def wan(v, dec=2):
    from decimal import Decimal, ROUND_HALF_UP
    q = Decimal(1).scaleb(-dec)
    return str((Decimal(str(v)) / 10000).quantize(q, rounding=ROUND_HALF_UP))


def wan_s(v, dec=2):
    return ('−' if v < 0 else ('+' if v > 0 else '')) + wan(abs(v), dec)


# ══ 读页面文本 ════════════════════════════════════════════════
with open(HTML, encoding='utf-8') as f:
    PAGE = f.read()

print('═' * 74)
print('① 渲染验收')
print('═' * 74)

ext, errs, svg_nodes, box, console = [], [], None, None, []
from playwright.sync_api import sync_playwright

os.makedirs(SHOTS, exist_ok=True)
with sync_playwright() as p:
    b = p.chromium.launch(channel='chrome')
    pg = b.new_page(viewport={'width': 1440, 'height': 1000}, device_scale_factor=1)
    pg.on('request', lambda r: ext.append(r.url) if not r.url.startswith('file://') else None)
    pg.on('console', lambda m: console.append(m.type + ': ' + m.text) if m.type in ('error', 'warning') else None)
    pg.on('pageerror', lambda e: errs.append(str(e)))
    pg.goto('file://' + HTML, wait_until='networkidle')
    pg.wait_for_timeout(500)
    svg_nodes = pg.evaluate("document.querySelectorAll('svg *').length")
    title_in_svg = pg.evaluate("document.querySelectorAll('svg title').length")
    br_in_svg = pg.evaluate("document.querySelectorAll('svg br').length")
    box = pg.evaluate("({w:document.documentElement.scrollWidth,h:document.documentElement.scrollHeight})")

    # ── 截图 ──
    shots = []
    def shot(sel, name):
        pg.locator(sel).screenshot(path=os.path.join(SHOTS, name))
        shots.append(os.path.join(SHOTS, name))
    shot('#blk1', '01_overview.png')
    shot('#blk2 .grp[data-group="复星集团"]', '02_group_inc_复星.png')
    shot('#blk2 .grp[data-group="佰奥莱博集团"]', '03_group_dec_佰奥莱博.png')
    shot('#blk3', '04_quality.png')
    shot('#blk2 .grplegend', '05_legend.png')
    shot('#blk2 .grp[data-group="天隆集团"]', '06_group_天隆_销售口述.png')
    shot('#blk2 .grp[data-group="丽珠集团"]', '07_group_丽珠_商机.png')
    b.close()

line('✅' if len(ext) == 0 else '❌', '外部请求数 = %d %s' % (len(ext), ext[:5]))
line('✅' if not errs else '❌', 'page error 数 = %d %s' % (len(errs), errs[:3]))
line('✅' if not console else '⚠️', 'console error/warning = %d %s' % (len(console), console[:3]))
line('✅' if svg_nodes and svg_nodes > 0 else '❌', 'SVG 节点数 = %s' % svg_nodes)
line('✅' if title_in_svg == 0 and br_in_svg == 0 else '❌',
     'SVG 内 <title>=%d  <br>=%d（须为 0）' % (title_in_svg, br_in_svg))
line('✅' if box['w'] <= 1440 else '❌',
     '横向：scrollWidth=%d（须 ≤1440）  scrollHeight=%d' % (box['w'], box['h']))

print()
print('═' * 74)
print('② 逐集团完整性（12 个集团块，逐个断言 + 两列表标题）')
print('═' * 74)
for g in GROUPS:
    blk_m = re.search(r'data-group="%s"' % re.escape(g), PAGE)
    if not blk_m:
        line('❌', '%s：集团块缺失' % g)
        continue
    # 取该块到下一块为止
    nxt = PAGE.find('data-group="', blk_m.end())
    seg = PAGE[blk_m.start(): nxt if nxt > 0 else len(PAGE)]
    has_inc = ('增量货号' in seg)
    has_dec = ('减量货号' in seg)
    ntr = seg.count('<tr>')
    line('✅' if (has_inc and has_dec) else '❌',
         '%-22s 增量列表=%s 减量列表=%s  明细行=%d' % (g, has_inc, has_dec, ntr))

# 空列表显式提示（无数据不得静默留白）
inc_empty = PAGE.count('本期无增量货号')
dec_empty = PAGE.count('本期无减量货号')
line('✅' if inc_empty >= 1 else '❌', '「本期无增量货号」显式提示出现 %d 次（军医大学）' % inc_empty)
line('✅' if dec_empty >= 3 else '❌', '「本期无减量货号」显式提示出现 %d 次（迪安/新产业/雅为）' % dec_empty)

print()
print('═' * 74)
print('③ 数字一致性（≥8 条 · 跨 ≥4 集团 · 与 sheet『集团x货号_Δ长表』逐条比对）')
print('═' * 74)
import openpyxl
wb = openpyxl.load_workbook(XLSX, data_only=True)
lt = list(wb['集团x货号_Δ长表'].iter_rows(min_row=2, values_only=True))

# 抽样：选 Δ 绝对值最大的若干集团，各取 2 条（保证跨 ≥4 集团、≥8 条）
from collections import defaultdict
by = defaultdict(list)
for r in lt:
    by[r[0]].append(r)
pick_groups = ['复星集团', '东方基因集团', '佰奥莱博集团', '天隆集团', '杭州迪安生物技术有限公司']
samples = []
for g in pick_groups:
    rows = sorted(by[g], key=lambda r: -abs(r[4]))
    for r in rows[:2]:
        samples.append(r)
samples = samples[:12]

def find_row(group, sku):
    """在该集团块里找货号精确匹配的表格行，抽出 5 个数值单元格。"""
    gm = re.search(r'data-group="%s"' % re.escape(group), PAGE)
    if not gm:
        return None
    nxt = PAGE.find('data-group="', gm.end())
    seg = PAGE[gm.start(): nxt if nxt > 0 else len(PAGE)]
    rm = re.search(r'<td class="c-sku">%s</td>(.*?)</tr>' % re.escape(sku), seg, re.S)
    if not rm:
        return None
    cells = re.findall(r'<td class="c-(?:a|s)"[^>]*>([^<]+)</td>', rm.group(1))
    return cells

n_ok = 0
for r in samples:
    g, sku, a25, a26, d = r[0], str(r[1]), float(r[2] or 0), float(r[3] or 0), float(r[4] or 0)
    cells = find_row(g, sku)
    exp = [wan(a25), wan(a26), wan_s(d)]
    if not cells or len(cells) < 3:
        line('❌', '%-10s %-28s 页面缺行' % (g[:8], sku))
        continue
    got = cells[:3]
    same = (got[0] == exp[0] and got[1] == exp[1] and got[2] == exp[2])
    n_ok += same
    line('✅' if same else '❌',
         '%-8s %-30s 源[25=%s 26=%s Δ=%s]  页[25=%s 26=%s Δ=%s]'
         % (g[:6], sku, exp[0], exp[1], exp[2], got[0], got[1], got[2]))
line('✅' if n_ok == len(samples) else '❌',
     '抽检 %d/%d 条一致（跨 %d 个集团）' % (n_ok, len(samples), len(pick_groups)))

print()
print('═' * 74)
print('④ 口径声明与旧口径黑名单')
print('═' * 74)
line('✅' if '刘子研名下数据' in PAGE else '❌', '含「刘子研名下数据」')
line('✅' if '不可比' in PAGE else '❌', '含「不可比」')
line('✅' if ('−52.7%' not in PAGE and '-52.7%' not in PAGE) else '❌', '不含旧口径 −52.7%')
# 主口径合计可见
line('✅' if ('586.78' in PAGE and '639.44' in PAGE and '52.67' in PAGE) else '❌',
     '主口径合计 586.78 / 639.44 / +52.67 可见')
line('✅' if '9.0%' in PAGE else '❌', '结论 +9.0% 可见')

print()
print('═' * 74)
print('⑤ 截图（%d 张）' % len(shots))
print('═' * 74)
for s in shots:
    print('  %s  %d bytes' % (s, os.path.getsize(s)))

print()
print('═' * 74)
print('⑥ 保护文件 mtime 未变（须早于本次产出 HTML 写入时间）')
print('═' * 74)
base_mt = os.stat(HTML).st_mtime
bad = 0
for rel in PROTECTED:
    p = os.path.join(ROOT, rel)
    if not os.path.exists(p):
        line('❌', '%-46s 不存在' % rel)
        continue
    mt = os.stat(p).st_mtime
    good = mt < base_mt
    bad += (not good)
    line('✅' if good else '❌',
         '%-46s mtime=%d  %s' % (rel, int(mt), '' if good else '(晚于产出！)'))
line('✅' if bad == 0 else '❌', '保护文件被改数 = %d' % bad)

# 备份存在
BAK = os.path.join(ROOT, '工具', '集团维度_增减归因看板_v1.html')
line('✅' if os.path.exists(BAK) else '❌', '改前备份 _v1.html 存在 = %s' % os.path.exists(BAK))

print()
print('═' * 74)
print('⑦ 数字未变：新页 vs _v1.html（改前备份）逐行比对 25/26/Δ/占比')
print('═' * 74)
import json

ROW_RE = re.compile(r'<td class="c-sku">(.*?)</td>(.*?)</tr>', re.S)
CELL_RE = re.compile(r'<td class="c-(?:a|s)"[^>]*>([^<]+)</td>')


def parse_amounts(page):
    """按 (集团, 货号) → [25同期, 26同期, Δ, 占比]（只取金额/占比单元格，忽略新列）。"""
    out = {}
    for gm in re.finditer(r'data-group="([^"]+)">(.*?)(?=data-group="|$)', page, re.S):
        g, seg = gm.group(1), gm.group(2)
        for rm in ROW_RE.finditer(seg):
            sku, tail = rm.group(1), rm.group(2)
            cells = CELL_RE.findall(tail)
            if len(cells) >= 4:
                out[(g, sku)] = cells[:4]
    return out


if os.path.exists(BAK):
    old = parse_amounts(open(BAK, encoding='utf-8').read())
    new = parse_amounts(PAGE)
    diff = [(k, old.get(k), new.get(k)) for k in sorted(set(old) | set(new))
            if old.get(k) != new.get(k)]
    for k, o, n in diff[:12]:
        print('   差异 %s：旧 %s → 新 %s' % (k, o, n))
    line('✅' if not diff else '❌',
         '货号行金额比对：旧 %d 行 ｜ 新 %d 行 ｜ 不一致 %d 行' % (len(old), len(new), len(diff)))

print()
print('═' * 74)
print('⑧ 归因完整性（显著项 100% 有归因文本或「未找到相关记录」；证据等级 100%）')
print('═' * 74)
ATTR_JSON = os.path.join(ROOT, '数据', '商机归因_20261011.json')
AD = json.load(open(ATTR_JSON, encoding='utf-8'))
sigs = [p for p in AD['pairs'] if p['sig']]
lv_cnt = {}
no_txt, no_lv, no_ev = [], [], []
for p in sigs:
    a = p.get('归因') or {}
    lv_cnt[a.get('证据等级')] = lv_cnt.get(a.get('证据等级'), 0) + 1
    if not (a.get('一句话') or '').strip():
        no_txt.append(p)
    if a.get('证据等级') not in ('系统有据', '销售口述', '推测待核'):
        no_lv.append(p)
    if a.get('证据等级') == '系统有据' and not (a.get('出处') or '').strip():
        no_ev.append(p)
line('✅' if not no_txt else '❌', '显著项总数 = %d ｜ 有归因文本 = %d ｜ 缺文本 = %d'
     % (len(sigs), len(sigs) - len(no_txt), len(no_txt)))
line('✅' if not no_lv else '❌', '证据等级三档标注率 = %.1f%%（缺标 %d）'
     % (100.0 * (len(sigs) - len(no_lv)) / max(len(sigs), 1), len(no_lv)))
tot_sys = sum(1 for p in sigs if (p.get('归因') or {}).get('证据等级') == '系统有据')
with_ev = sum(1 for p in sigs if (p.get('归因') or {}).get('证据等级') == '系统有据'
              and ((p.get('归因') or {}).get('出处') or '').strip())
line('✅' if not no_ev else '❌', '「系统有据」附出处率 = %.1f%%（%d/%d）'
     % (100.0 * with_ev / max(tot_sys, 1), with_ev, tot_sys))
for k in ('系统有据', '销售口述', '推测待核'):
    print('   %s：%d 项' % (k, lv_cnt.get(k, 0)))
nf = sum(1 for p in sigs if (p.get('归因') or {}).get('一句话') == '未找到相关记录')
print('   其中「未找到相关记录」= %d 项（显式标注，不编原因）' % nf)
print('   类型分布：', AD['计数'].get('类型分布'))
print('   挖掘引擎：', AD['计数'].get('挖掘引擎_活动信号'), AD['计数'].get('挖掘引擎_归因'))
# 页面确实带上了两列
line('✅' if PAGE.count('商机（飞书）') >= 12 else '❌', '页面「商机（飞书）」表头 %d 处' % PAGE.count('商机（飞书）'))
line('✅' if PAGE.count('风险 / 机会') >= 12 else '❌', '页面「风险 / 机会」表头 %d 处' % PAGE.count('风险 / 机会'))
npill = len(re.findall(r'class="pill pill-(?:opp|risk|na)"', PAGE))
nlv = len(re.findall(r'class="lv lv-(?:sys|oral|guess|na)"', PAGE))
line('✅' if npill > 0 and nlv > 0 else '❌', '页面 pill %d 个 ｜ 证据徽标 %d 个' % (npill, nlv))

print()
print('═' * 74)
print('⑨ 挖掘可溯源（活动信号 ≥5 条带日期/原文片段）')
print('═' * 74)
SIG = os.path.join(ROOT, '数据', '_商机_活动信号.json')
if os.path.exists(SIG):
    items = json.load(open(SIG, encoding='utf-8')).get('items') or []
    withsrc = [s for s in items if (s.get('依据') or '').strip() and (s.get('日期') or '').strip()]
    for s in withsrc[:6]:
        print('   [%s] %s ｜ %s ｜ 依据：%s' % (
            s.get('日期'), s.get('类别'), (s.get('摘要') or '')[:24], (s.get('依据') or '')[:52]))
    line('✅' if len(withsrc) >= 5 else '❌',
         '活动信号 %d 条 ｜ 带日期+原文片段 %d 条（≥5）' % (len(items), len(withsrc)))
else:
    line('❌', '未找到 %s' % SIG)

print()
print('═' * 74)
print('⑩ 文字完整显示（表格文字列零截断；商机 / 归因一句话逐条比对可见文本）')
print('═' * 74)
import html as _html
_vis = re.sub(r'<style.*?</style>', '', PAGE, flags=re.S)
_vis = re.sub(r'<script.*?</script>', '', _vis, flags=re.S)
_vis = re.sub(r'\stitle="[^"]*"', '', _vis)          # 工具提示不算可见文本
_td_plain = [(_cls, _html.unescape(re.sub(r'<[^>]+>', '', m.group(2))))
             for _cls, m in ((mm.group(1), mm) for mm in
                             re.finditer(r'<td class="(c-[a-z]+)"[^>]*>(.*?)</td>', PAGE, re.S))]
_tr = [(c, t) for c, t in _td_plain if '…' in t and c != 'c-attr']
line('✅' if not _tr else '❌', '货号/物料名称/商机 单元格含「…」截断 = %d（须 0）%s'
     % (len(_tr), [t[:24] for _, t in _tr[:2]]))
_attr_ell = [t for c, t in _td_plain if c == 'c-attr' and '…' in t]
line('✅' if not _attr_ell else '❌', '风险/机会 单元格含「…」截断 = %d（须 0）%s'
     % (len(_attr_ell), [t[:24] for t in _attr_ell[:2]]))
_rowsv = list((AD.get('看板行归因') or {}).values())
_why = {r.get('why') for r in _rowsv if r.get('sig') and r.get('why')}   # 页面实际渲染的归因文本
_o = {r.get('opp') for r in _rowsv if r.get('opp')}
_surfaced = sum(1 for r in _rowsv if r.get('sig'))
print('   信息：显著项 %d ｜ 已在看板行呈现的显著行 %d（其余落在未列示的货号行上）'
      % (len(sigs), _surfaced))
_mw = sorted(w for w in _why if w not in _vis)
_mo = sorted(o for o in _o if o not in _vis)
line('✅' if not _mw else '❌', '归因一句话 %d 种 ｜ 可见文本缺失 %d 种 %s' % (len(_why), len(_mw), _mw[:2]))
line('✅' if not _mo else '❌', '商机取值 %d 种 ｜ 可见文本缺失 %d 种 %s' % (len(_o), len(_mo), _mo[:2]))
_nsrc = len(re.findall(r'<span class="src"', PAGE))
_src_expect = sum(1 for r in _rowsv if r.get('sig') and (r.get('src') or '').strip())
line('✅' if _nsrc == _src_expect else '❌',
     '可见「出处」行 %d 条 ｜ 应可见（有出处的显著行）%d 条 —— 出处不得只藏在 tooltip'
     % (_nsrc, _src_expect))
line('✅' if _vis.count('…') == 0 else '❌',
     '可见文本残留「…」= %d（须 0；tooltip 内模型引用里的缩写不计）' % _vis.count('…'))

print()
print('═' * 74)
print('总结论：%s' % ('✅ 全部通过' if ok_all else '❌ 存在失败项'))
print('═' * 74)
sys.exit(0 if ok_all else 1)
