#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""科研货号「每 test 成交价 · 浙江 vs 广东」统计图生成器（1080px 自包含 PNG）。

产物：
  营销活动/科研价格分析_20261010/BR1D303-01_浙江广东_每test价.png
  营销活动/科研价格分析_20261010/BR1E102-41_浙江广东_每test价.png

跑法： env -u PYTHONPATH /usr/bin/python3 gen_科研价格分析_浙江广东.py
      （该解释器同机具备 playwright + PIL）

口径：标准销售订单 · 已审核 · 非关联方 · 非作废 · 0 元试用/测试单已剔除（按合同日期 2026-08-01~09-30）
      每 test 价 = 每包价 ÷ 包装 T 数。数据写死在本文件（权威，来自任务书）。
装饰：内联 SVG 画散点，禁 <title> / <br>（会静默吞掉后续图形）→ 生成器断言。
"""
import io
import math
import os
import statistics
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import brand_kit as BK  # noqa: E402
from PIL import Image  # noqa: E402
from playwright.sync_api import sync_playwright  # noqa: E402

ROOT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
OUTDIR = os.path.join(ROOT, '营销活动', '科研价格分析_20261010')
SCRATCH = os.environ.get('TMPDIR', '/tmp')
W = 1080
BLUE = '#1852B6'
TEAL = '#0E8F86'
GREY = '#8A94A6'
RED = '#E8443C'


def R(cust, short, packs, per_pack, per_test):
    return {'cust': cust, 'short': short, 'packs': packs,
            'per_pack': per_pack, 'per_test': per_test}


# ═══════════════════ 图 1：BR1D303-01（100T/包）═══════════════════
FIG1 = {
    'fn': 'BR1D303-01_浙江广东_每test价.png',
    'sku': 'BR1D303-01',
    'name': 'AcuGenix Neoscript All-in-One cDNA Synthesis',
    't': 100,
    'regions': [
        {'key': '浙江', 'color': BLUE, 'agg': (7, 15, 9613.20), 'rows': [
            R('浙江大学', '浙大', 1, 920.00, 9.2000),
            R('杭州奕文生物科技有限公司', '杭州奕文', 1, 700.00, 7.0000),
            R('杭州智科芯生物科技有限公司', '杭州智科芯', 1, 828.00, 8.2800),
            R('浙江大学', '浙大', 5, 720.00, 7.2000),
            R('国科大杭州高等研究院', '国科大杭高院', 1, 920.00, 9.2000),
            R('杭州奕文生物科技有限公司', '杭州奕文', 1, 745.20, 7.4520),
            R('杭州师范大学', '杭师大', 5, 380.00, 3.8000),
        ]},
        {'key': '广东', 'color': TEAL, 'agg': (7, 18, 5722.00), 'rows': [
            R('南方医科大学', '南方医科大', 2, 368.00, 3.6800),
            R('华南师范大学', '华南师大', 1, 318.00, 3.1800),
            R('中山大学附属口腔医院', '中大口腔', 1, 600.00, 6.0000),
            R('中山大学附属口腔医院', '中大口腔', 5, 300.00, 3.0000),
            R('中山大学附属第一医院', '中大附一', 3, 250.00, 2.5000),
            R('中山大学附属第一医院', '中大附一', 5, 300.00, 3.0000),
            R('无极生命（深圳）生物技术有限公司', '无极生命', 1, 318.00, 3.1800),
        ]},
    ],
    'other': None,
}

# ═══════════════════ 图 2：BR1E102-41（500T/包）═══════════════════
FIG2 = {
    'fn': 'BR1E102-41_浙江广东_每test价.png',
    'sku': 'BR1E102-41',
    'name': 'AcuGenix FastAmpli qPCR Master Mix Universal',
    't': 500,
    'regions': [
        {'key': '浙江', 'color': BLUE, 'agg': (8, 15, 5130.50), 'rows': [
            R('浙江省中医院、浙江中医药大学附属第一医院', '浙中医大附一', 1, 610.00, 1.2200),
            R('温州医科大学', '温州医科大', 3, 220.00, 0.4400),
            R('温州医科大学', '温州医科大', 1, 580.00, 1.1600),
            R('浙江大学', '浙大', 1, 610.00, 1.2200),
            R('杭州奕文生物科技有限公司', '杭州奕文', 1, 449.30, 0.8986),
            R('国科大杭州高等研究院', '国科大杭高院', 1, 610.00, 1.2200),
            R('杭州师范大学', '杭师大', 5, 220.00, 0.4400),
            R('杭州奕文生物科技有限公司', '杭州奕文', 2, 255.60, 0.5112),
        ]},
        {'key': '广东', 'color': TEAL, 'agg': (7, 30, 8475.50), 'rows': [
            R('暨南大学', '暨南大学', 2, 610.00, 1.2200),
            R('华南师范大学', '华南师大', 1, 213.00, 0.4260),
            R('中山火炬开发区人民医院', '中山火炬医院', 1, 579.50, 1.1590),
            R('中山大学附属口腔医院', '中大口腔', 10, 250.00, 0.5000),
            R('中山大学附属第一医院', '中大附一', 5, 250.00, 0.5000),
            R('中山大学附属第一医院', '中大附一', 10, 250.00, 0.5000),
            R('无极生命（深圳）生物技术有限公司', '无极生命', 1, 213.00, 0.4260),
        ]},
    ],
    'other': R('贵州省博源康生物科技有限公司', '贵州博源康', 1, 610.00, 1.2200),
}


# ─────────────────────────── 工具 ───────────────────────────
def money(x):
    return '{:,.2f}'.format(x)


def g(x):
    return ('%g' % round(x, 4))


def region_stats(reg):
    pts = [r['per_test'] for r in reg['rows']]
    return {'n': len(reg['rows']), 'lo': min(pts), 'hi': max(pts),
            'med': statistics.median(pts),
            'packs': sum(r['packs'] for r in reg['rows']),
            'amt': sum(r['packs'] * r['per_pack'] for r in reg['rows'])}


def make_axis(lo, hi):
    pad = (hi - lo) * 0.05 or 0.1
    a, b = lo - pad, hi + pad
    raw = (b - a) / 4.0
    mag = 10 ** math.floor(math.log10(raw))
    step = 10 * mag
    for m in (1, 2, 2.5, 5, 10):
        if raw <= m * mag:
            step = m * mag
            break
    ymin = math.floor(a / step) * step
    ymax = math.ceil(b / step) * step
    ticks, v = [], ymin
    while v <= ymax + 1e-9:
        ticks.append(round(v, 6))
        v += step
    return ymin, ymax, ticks, step


def fmt_tick(v, step):
    if step >= 1:
        return ('%g' % v)
    d = max(0, int(round(-math.log10(step))))
    return ('%.*f' % (d, v))


def scatter_svg(reg, ymin, ymax, ticks, step):
    n = len(reg['rows'])
    SW, SH = 470, 356
    L, Rr, T, B = 64, 12, 20, 106
    pw = SW - L - Rr
    ph = SH - T - B

    def X(i):
        return L + pw * (i + 0.5) / n

    def Y(v):
        return T + ph * (ymax - v) / (ymax - ymin)

    p = ['<svg viewBox="0 0 %d %d" width="100%%" xmlns="http://www.w3.org/2000/svg">' % (SW, SH)]
    p.append('<rect x="%g" y="%g" width="%g" height="%g" fill="#FAFCFF"/>' % (L, T, pw, ph))
    for t in ticks:
        y = Y(t)
        p.append('<rect x="%g" y="%g" width="%g" height="1" fill="#E6EBF3"/>' % (L, y, pw))
        p.append('<text x="%g" y="%g" text-anchor="end" font-size="11" fill="%s">%s</text>'
                 % (L - 6, y + 3.5, GREY, fmt_tick(t, step)))
    p.append('<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#C3CDDE" stroke-width="1.2"/>'
             % (L, T, L, T + ph))
    p.append('<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#C3CDDE" stroke-width="1.2"/>'
             % (L, T + ph, L + pw, T + ph))
    # 中位线 + 右上角中位标签（独立胶囊，避开散点标签）
    med = statistics.median([r['per_test'] for r in reg['rows']])
    ym = Y(med)
    p.append('<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="%s" stroke-width="1.6" '
             'stroke-dasharray="6 4"/>' % (L, ym, L + pw, ym, RED))
    cw, ch = 104, 20
    cx, cy = L + pw - cw, T + 4
    p.append('<rect x="%g" y="%g" width="%g" height="%g" rx="6" fill="#FDECEA" '
             'stroke="#F3BDB5"/>' % (cx, cy, cw, ch))
    p.append('<text x="%g" y="%g" text-anchor="middle" font-size="11.5" font-weight="700" '
             'fill="%s">中位 %s</text>' % (cx + cw / 2.0, cy + 14, RED, ('%.4f' % med)))
    # 散点 + 每包价标签（白描边防与中位线打架）+ x 客户短名
    for i, r in enumerate(reg['rows']):
        x, y = X(i), Y(r['per_test'])
        p.append('<circle cx="%g" cy="%g" r="5.5" fill="%s" stroke="#fff" stroke-width="1.6"/>'
                 % (x, y, reg['color']))
        p.append('<text x="%g" y="%g" text-anchor="middle" font-size="10.5" font-weight="700" '
                 'fill="#3A4658" stroke="#fff" stroke-width="3" paint-order="stroke" '
                 'stroke-linejoin="round">%s</text>' % (x, y - 9, g(r['per_pack'])))
        p.append('<text x="%g" y="%g" transform="rotate(-42 %g %g)" text-anchor="end" '
                 'font-size="10.5" fill="#44506A">%s</text>'
                 % (x, T + ph + 14, x, T + ph + 14, r['short']))
    p.append('</svg>')
    return ''.join(p)


def col_html(reg, ymin, ymax, ticks, step, fig):
    st = region_stats(reg)
    n_pk, amt = reg['agg'][1], reg['agg'][2]
    cards = (
        '<div class="stats">'
        '<div class="sc s2"><div class="v">%d<small>单</small></div><div class="l">订单数</div></div>'
        '<div class="sc s2"><div class="v">%d<small>包</small></div><div class="l">包装数</div></div>'
        '<div class="sc s2"><div class="v">%s<small>元</small></div><div class="l">成交金额</div></div>'
        '<div class="sc s3"><div class="v">%.4f – %.4f</div><div class="l">每 test 价区间（元）</div></div>'
        '<div class="sc s3"><div class="v">%.4f</div><div class="l">每 test 价中位数（元）</div></div>'
        '</div>' % (reg['agg'][0], n_pk, money(amt), st['lo'], st['hi'], st['med']))

    other = ''
    if fig.get('other') and reg['key'] == '广东':
        o = fig['other']
        other = ('<div class="other">其他（未归区域，不计入上方统计）：%s · %d 包 · %s 元/包 · 每 test %.4f</div>'
                 % (o['cust'], o['packs'], money(o['per_pack']), o['per_test']))

    return ('<div class="region" style="--ac:%s">'
            '<div class="rhead"><i></i><span class="rname">%s</span>'
            '<span class="rsub">%d 单 · %d 包 · %s 元</span></div>'
            '%s'
            '<div class="chart">%s</div>%s</div>'
            % (reg['color'], reg['key'], reg['agg'][0], n_pk, money(amt),
               cards, scatter_svg(reg, ymin, ymax, ticks, step), other))


def build_html(fig):
    allpts = [r['per_test'] for reg in fig['regions'] for r in reg['rows']]
    ymin, ymax, ticks, step = make_axis(min(allpts), max(allpts))
    cols = ''.join(col_html(reg, ymin, ymax, ticks, step, fig) for reg in fig['regions'])
    logo = BK.logo_data_uri()
    logo2 = BK.logo_data_uri(48)
    css = """
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#EEF3FB}
.wrap{width:1080px;background:#fff;font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;color:#16202F}
.topbar{display:flex;align-items:center;gap:14px;padding:14px 26px;
  background:linear-gradient(120deg,#0F3A8A 0%,#1852B6 55%,#2E7BE0 100%);color:#fff}
.topbar .lg{background:#fff;border-radius:8px;padding:4px 10px;display:flex;align-items:center}
.topbar .lg img{height:34px;display:block}
.topbar .bn{line-height:1.25}
.topbar .bn b{font-size:15px;letter-spacing:.6px;display:block}
.topbar .bn span{font-size:11px;opacity:.86;letter-spacing:2px;display:block}
.topbar .sl{margin-left:auto;font-size:14px;font-weight:700;letter-spacing:3px;text-shadow:0 1px 2px rgba(0,0,0,.15)}
.hero{padding:20px 26px 14px;border-bottom:1px solid #E6EBF3;background:#F7FAFF}
.hero .hline{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
.hero .sku{font-size:34px;font-weight:900;color:#0F2A5C;letter-spacing:.5px}
.hero .badge{font-size:14px;font-weight:800;color:#fff;background:#1852B6;border-radius:999px;padding:5px 14px}
.hero .pname{font-size:17px;font-weight:700;color:#3A4658;margin-top:8px}
.hero .meta{font-size:13px;color:#5A6475;margin-top:8px}
.caliber{font-size:12px;color:#7A8598;line-height:1.6;margin-top:9px}
.caliber b{color:#1852B6}
.grid{display:flex;gap:16px;padding:16px 26px 20px;align-items:stretch}
.region{flex:1;min-width:0;border:1px solid #E3E9F3;border-radius:16px;background:#fff;
  padding:14px 14px 12px;box-shadow:0 6px 18px rgba(16,40,90,.06);display:flex;flex-direction:column}
.rhead{display:flex;align-items:center;gap:9px;margin-bottom:11px}
.rhead i{width:6px;height:22px;border-radius:3px;background:var(--ac);display:block;flex:none}
.rhead .rname{font-size:20px;font-weight:900;color:var(--ac);letter-spacing:1px}
.rhead .rsub{font-size:12px;color:#7A8598;margin-left:auto}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:10px}
.sc{background:#F4F8FF;border:1px solid #E1EAF8;border-radius:10px;padding:8px 6px;text-align:center}
.sc.s2{grid-column:span 2}
.sc.s3{grid-column:span 3}
.sc .v{font-size:19px;font-weight:900;color:#0F2A5C;line-height:1.15;white-space:nowrap}
.sc .v small{font-size:11px;font-weight:700;color:#8492A6;margin-left:2px}
.sc .l{font-size:10.5px;color:#7A8598;margin-top:4px;white-space:nowrap}
.chart{flex:1;display:flex;align-items:center}
.chart svg{display:block;width:100%;height:auto}
.other{font-size:11px;color:#8A94A6;line-height:1.5;margin-top:8px;background:#F6F8FC;
  border:1px dashed #DCE3EE;border-radius:8px;padding:7px 9px}
.legend{font-size:11.5px;color:#6B7688;padding:0 26px 6px;display:flex;gap:18px;flex-wrap:wrap}
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:-1px;margin-right:4px}
.legend .ln{display:inline-block;width:16px;height:0;border-top:2px dashed #E8443C;vertical-align:3px;margin-right:4px}
.foot{display:flex;align-items:center;gap:12px;padding:12px 26px;background:#0D2A52;color:#C9D8EE}
.foot img{height:26px;background:#fff;border-radius:6px;padding:2px 7px;display:block}
.foot .fs{font-size:14px;font-weight:700;letter-spacing:3px;color:#fff}
.foot .fm{margin-left:auto;font-size:10.5px;opacity:.82}
"""
    html = (
        '<!DOCTYPE html><html><head><meta charset="utf-8"><style>%s</style></head><body>'
        '<div class="wrap">'
        '<div class="topbar"><div class="lg"><img src="%s"></div>'
        '<div class="bn"><b>BIORI BIOTECH</b><span>宝锐生物</span></div>'
        '<div class="sl">%s</div></div>'
        '<div class="hero"><div class="hline"><span class="sku">%s</span>'
        '<span class="badge">每 test 成交价</span></div>'
        '<div class="pname">%s</div>'
        '<div class="meta">期间 2026-08-01 ~ 2026-09-30 ｜ 包装规格 %d T/包 ｜ 浙江 vs 广东 区域对比</div>'
        '<div class="caliber">口径：<b>标准销售订单 · 已审核 · 非关联方 · 非作废 · 0 元试用/测试单已剔除</b>'
        '（按合同日期） ｜ 每 test 价 = 每包价 ÷ 包装 T 数 ｜ 点 = 单个订单，点旁数字 = 每包价（元）</div>'
        '</div>'
        '<div class="legend">'
        '<span><i style="background:%s"></i>浙江</span>'
        '<span><i style="background:%s"></i>广东</span>'
        '<span><span class="ln"></span>每 test 价中位数</span>'
        '<span>同客户多单 → 多点</span></div>'
        '<div class="grid">%s</div>'
        '<div class="foot"><img src="%s"><span class="fs">%s</span>'
        '<span class="fm">资料来源：ERP 销售订单 · 生成 2026-10-10</span></div>'
        '</div></body></html>'
    ) % (css, logo, BK.SLOGAN, fig['sku'], fig['name'], fig['t'], BLUE, TEAL,
         cols, logo2, BK.SLOGAN)
    return html


def recon(fig):
    print('\n══ %s ｜ %s（%dT/包）══' % (fig['sku'], fig['name'], fig['t']))
    tot_n = tot_pk = 0
    tot_amt = 0.0
    for reg in fig['regions']:
        st = region_stats(reg)
        n_pk, amt = reg['agg'][1], reg['agg'][2]
        tot_n += reg['agg'][0]
        tot_pk += n_pk
        tot_amt += amt
        flag = '' if st['packs'] == n_pk else '  ⚠️ 明细包数=%d ≠ 任务书包数=%d' % (st['packs'], n_pk)
        aflag = '' if abs(st['amt'] - amt) < 0.005 else '  ⚠️ 明细金额=%.2f ≠ 任务书=%.2f' % (st['amt'], amt)
        print('  %-4s 单%d/%d  包%d/%d  金额 %.2f/%.2f  每test %.4f~%.4f 中位 %.4f%s%s'
              % (reg['key'], reg['agg'][0], st['n'], n_pk, st['packs'],
                 st['amt'], amt, st['lo'], st['hi'], st['med'], flag, aflag))
    if fig.get('other'):
        o = fig['other']
        print('  其他 %s · %d包 · %.2f元/包 · 每test %.4f（未归区域）'
              % (o['cust'], o['packs'], o['per_pack'], o['per_test']))
    print('  合计（区域）：%d单 / %d包 / %.2f元' % (tot_n, tot_pk, tot_amt))


def main():
    os.makedirs(OUTDIR, exist_ok=True)
    ok = True
    for fig in (FIG1, FIG2):
        recon(fig)
        html = build_html(fig)
        # 生成器断言：SVG 陷阱守卫
        assert '<title' not in html.lower(), 'SVG 内出现 <title>，会吞掉后续图形'
        assert '<br' not in html.lower(), '出现 <br>，SVG 内会吞掉后续图形'
        assert html.count('<rect') > 5, '<rect> 数量不足（%d）' % html.count('<rect')
        print('  ✅ 生成器断言：<title>=0  <br>=0  <rect>=%d' % html.count('<rect'))

        src_path = os.path.join(SCRATCH, '_price_%s.html' % fig['sku'])
        io.open(src_path, 'w', encoding='utf-8').write(html)

        with sync_playwright() as pw:
            br = pw.chromium.launch(channel='chrome', headless=True)
            pg = br.new_page(viewport={'width': W, 'height': 1200}, device_scale_factor=1)
            reqs = []
            pg.on('request', lambda r: reqs.append(r.url))
            pg.goto('file://' + src_path, wait_until='load')
            pg.wait_for_timeout(400)
            info = pg.evaluate("""() => ({
                svgNodes: document.querySelectorAll('svg *').length,
                titles: document.querySelectorAll('title').length,
                rects: document.querySelectorAll('rect').length,
                circles: document.querySelectorAll('circle').length,
                w: document.querySelector('.wrap').scrollWidth,
                h: document.querySelector('.wrap').scrollHeight
            })""")
            out = os.path.join(OUTDIR, fig['fn'])
            # 收紧视口到真实内容高度 → 去掉底部大片留白
            pg.set_viewport_size({'width': W, 'height': int(info['h'])})
            pg.wait_for_timeout(120)
            pg.screenshot(path=out, full_page=True)
            br.close()

        ext = [u for u in reqs if not (u.startswith('file://') or u.startswith('data:'))]
        im = Image.open(out)
        print('  渲染断言：svg 节点=%d  <title>=%d  <rect>=%d  <circle>=%d  '
              'wrap=%dx%d  外链请求=%d'
              % (info['svgNodes'], info['titles'], info['rects'], info['circles'],
                 info['w'], info['h'], len(ext)))
        if ext:
            print('    ⚠️ 外链：', ext[:5])
        print('  PNG：%s  %dx%d  %.1f KB' % (out, im.width, im.height,
                                            os.path.getsize(out) / 1024.0))
        good = (info['svgNodes'] > 0 and info['titles'] == 0 and len(ext) == 0
                and im.width == W and info['w'] <= W)
        ok = ok and good
        print('  %s' % ('✅ 通过' if good else '❌ 未通过'))
    print('\n%s' % ('✅ 两张图全部通过' if ok else '❌ 存在失败项'))
    return 0 if ok else 1


if __name__ == '__main__':
    sys.exit(main())
