#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成《生命科学销售部（科研）销售金额排名》自包含 HTML 交付物。

输入：销售分析/科研销售_按人与区域_20260820-20260930.json
输出：销售分析/生命科学销售部_销售额明细_20260820-20260930.html
要求：零外链、内联数据、可排序/筛选/CSV 导出、原始记录弹窗、品牌套件、口径说明。
⚠️ 含真实客户名与金额 → 只出本地文件，不上工作台、不推 CDN。
"""
import io
import json
import os
import re
import subprocess
import sys
import tempfile

ROOT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
sys.path.insert(0, os.path.join(ROOT, '脚本'))
import brand_kit as BK                                    # noqa: E402

SRC = os.path.join(ROOT, '销售分析', '科研销售_按人与区域_20260820-20260930.json')
OUT = os.path.join(ROOT, '销售分析', '生命科学销售部_销售额明细_20260820-20260930.html')
D = json.load(io.open(SRC, encoding='utf-8'))

CSS = """
*{box-sizing:border-box}
body{margin:0;background:#f5f7fa;color:#1f2328;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:1560px;margin:0 auto;padding:18px}
h1{font-size:23px;font-weight:900;margin:6px 0 2px;color:#0F3A8A}
.sub{color:#5f6368;font-size:13px;margin-bottom:14px}
.cards{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px}
.card{background:#fff;border-radius:10px;padding:12px 16px;min-width:132px;border-left:4px solid #1852B6;
      box-shadow:0 1px 3px rgba(16,24,40,.08)}
.card .k{font-size:12px;color:#5f6368}
.card .v{font-size:22px;font-weight:900;color:#1852B6;line-height:1.25}
.card .u{font-size:11px;color:#98a2b3}
.sec{background:#fff;border-radius:10px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(16,24,40,.08)}
.sec h2{font-size:16px;font-weight:800;margin:0 0 10px;color:#0F3A8A;display:flex;align-items:center;gap:8px}
.sec h2 .n{background:#E8F0FE;color:#1852B6;font-size:12px;border-radius:6px;padding:1px 7px}
.bar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
input[type=text]{border:1px solid #d0d5dd;border-radius:8px;padding:6px 10px;font-size:13px;min-width:210px}
.btn{border:1px solid #d0d5dd;background:#fff;border-radius:8px;padding:6px 11px;font-size:13px;cursor:pointer;color:#344054}
.btn:hover{background:#f2f6fd;border-color:#1852B6;color:#1852B6}
.btn.p{background:#1852B6;border-color:#1852B6;color:#fff}
.btn.p:hover{background:#0F3A8A}
.tw{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:7px 9px;border-bottom:1px solid #eef1f5;white-space:nowrap}
th{background:#f0f4fb;color:#0F3A8A;font-weight:700;cursor:pointer;user-select:none;position:sticky;top:0}
th:hover{background:#e3ecfb}
th.num,td.num{text-align:right}
th.ctr,td.ctr{text-align:center}
tbody tr:hover{background:#f7faff}
td.l{text-align:left}
.rk{font-weight:900;color:#1852B6}
.rk1{background:#FFF4D6}
.rk2{background:#F0F4F8}
.rk3{background:#FBEEE4}
.tag{display:inline-block;font-size:11px;border-radius:5px;padding:1px 6px;background:#E8F0FE;color:#1852B6}
.tag.zj{background:#E7F6EE;color:#0E7A45}
.tag.gz{background:#FDF0E6;color:#B45309}
.tag.out{background:#FDECEC;color:#C0392B}
.note{font-size:12.5px;color:#5f6368;margin:4px 0 0}
.warn{background:#FFF8E6;border-left:4px solid #E9A800;border-radius:8px;padding:10px 13px;margin-bottom:14px;font-size:13px}
.warn b{color:#8A6100}
.foot{background:#fff;border-radius:10px;padding:14px 16px;font-size:12.5px;color:#475467;line-height:1.85}
.foot h3{font-size:14px;color:#0F3A8A;margin:0 0 8px}
.foot code{background:#f1f4f9;border-radius:4px;padding:1px 5px;font-size:12px}
#modal{position:fixed;inset:0;background:rgba(16,24,40,.55);display:none;z-index:99}
#modal.on{display:flex;align-items:center;justify-content:center;padding:24px}
#mbox{background:#fff;border-radius:12px;width:min(1400px,96vw);height:86vh;display:flex;flex-direction:column;overflow:hidden}
#mhead{padding:12px 16px;border-bottom:1px solid #eef1f5;display:flex;align-items:center;gap:10px}
#mhead h3{margin:0;font-size:15px;color:#0F3A8A}
#mbody{flex:1;overflow:auto;padding:0 16px 16px}
pre{background:#f7f9fc;border:1px solid #eef1f5;border-radius:8px;padding:10px;font-size:11.5px;max-height:340px;overflow:auto}
.sp{display:flex;gap:10px;align-items:flex-end;height:110px;padding:6px 2px 0;overflow-x:auto}
.sp .d{flex:0 0 26px;text-align:center}
.sp .d i{display:block;background:#1852B6;border-radius:3px 3px 0 0;width:100%}
.sp .d span{font-size:9.5px;color:#98a2b3;display:block;margin-top:3px;transform:rotate(-60deg);transform-origin:top left;height:26px}
"""


def esc(s):
    return (str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            .replace('"', '&quot;'))


def rows_html(rs, cols):
    out = []
    for r in rs:
        tds = []
        for c in cols:
            k, lbl, kind = c[0], c[1], (c[2] if len(c) > 2 else '')
            v = r.get(k, '')
            if kind == 'num':
                tds.append('<td class="num">%s</td>' % ('—' if v in ('', None) else f'{v:,.4f}'.rstrip('0').rstrip('.') if isinstance(v, float) else v))
            elif kind == 'ctr':
                tds.append('<td class="ctr">%s</td>' % v)
            elif kind == 'rk':
                cls = ' rk1' if v == 1 else (' rk2' if v == 2 else (' rk3' if v == 3 else ''))
                tds.append('<td class="ctr%s"><span class="rk">%s</span></td>' % (cls, v if v else '—'))
            elif kind == 'big':
                cls = ' rk1' if r.get('名次') == 1 else (' rk2' if r.get('名次') == 2 else (' rk3' if r.get('名次') == 3 else ''))
                tds.append('<td class="num%s"><b>%s</b></td>' % (cls, v))
            elif kind == 'reg':
                cls = 'zj' if '浙江' in str(v) else ('gz' if '广州' in str(v) else '')
                tds.append('<td class="ctr"><span class="tag %s">%s</span></td>' % (cls, esc(v)))
            elif kind == 'status':
                cls = 'out' if v == '离职' else ''
                tds.append('<td class="ctr"><span class="tag %s">%s</span></td>' % (cls, esc(v)))
            else:
                tds.append('<td class="l">%s</td>' % esc(v))
        out.append('<tr>%s</tr>' % ''.join(tds))
    return ''.join(out)


def table(tid, cols, rs):
    head = ''.join('<th class="%s" data-k="%s">%s</th>' % (c[2] if len(c) > 2 and c[2] in ('num', 'ctr') else '', c[0], esc(c[1])) for c in cols)
    return ('<div class="bar"><input type="text" id="q_%s" placeholder="筛选…"><button class="btn" onclick="csv(\'%s\')">导出 CSV</button>'
            '<span class="note" id="n_%s"></span></div>'
            '<div class="tw"><table id="%s"><thead><tr>%s</tr></thead><tbody>%s</tbody></table></div>'
            % (tid, tid, tid, tid, head, rows_html(rs, cols)))


p, regs, custs, skus, daily = D['people'], D['regions'], D['customers'], D['skus'], D['daily']
st = D['stats']
mx = max([x['金额'] for x in daily] or [1])
spark = ''.join('<div class="d" title="%s：%s 万"><i style="height:%.0fpx"></i><span>%s</span></div>'
                % (x['日期'], x['金额'], max(2, x['金额'] / mx * 76), x['日期'][5:]) for x in daily)

warn = ''
if D.get('rosterless'):
    items = '；'.join('%s <b>%s 万</b>（%s，客户：%s）' % (x['销售员'], x['金额'], '、'.join(x['月份']), x['客户样例'])
                      for x in D['rosterless'])
    warn += ('<div class="warn"><b>⚠️ 名册缺口（如实披露，未计入本表）：</b>区间内有成交但不在《人员清单》也无归属映射：%s。<br>'
             '他们不属于科研口径，故不计入排名；但这说明「人员清单」覆盖不全，其它部门报表可能被低估。</div>' % items)
if D.get('outsiders'):
    items = '；'.join('%s（%s / %s）<b>%s 万</b>' % (x['销售员'], x['部门'], x['区域'], x['金额']) for x in D['outsiders'])
    warn += ('<div class="warn"><b>⚠️ 未计入本部门、但持科研线下单权限：</b>%s。<br>'
             '该金额<b>比整个科研部门同期合计（%s 万）还高</b>，按部门口径正确地排除在外——'
             '是否并入请一句话决定。</div>' % (items, f"{D['total']:,.4f}"))

html = f"""<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>生命科学销售部（科研）销售金额排名 · {D['start']} ~ {D['end']}</title>
<style>{CSS}</style></head><body>
<div class="wrap">
<h1>生命科学销售部（科研）· 销售金额排名</h1>
<div class="sub">区间 <b>{D['start']} ~ {D['end']}</b>（按合同日期）｜ 数据源：<code>{esc(D['src'])}</code>（合同日期最大 {D['src_max_date']}）｜ 生成 {D['generated']}</div>

<div class="cards">
  <div class="card"><div class="k">部门合计</div><div class="v">{D['total']:,.4f}</div><div class="u">万元（含税）</div></div>
  <div class="card"><div class="k">客户数</div><div class="v">{st['客户数']}</div><div class="u">去重</div></div>
  <div class="card"><div class="k">订单数</div><div class="v">{st['订单数']}</div><div class="u">单据编号去重</div></div>
  <div class="card"><div class="k">货号数</div><div class="v">{st['货号数']}</div><div class="u">区间内</div></div>
  <div class="card"><div class="k">单均</div><div class="v">{D['total']/max(st['订单数'],1):,.4f}</div><div class="u">万元/单</div></div>
</div>

{warn}

<div class="sec"><h2><span class="n">走势</span>按日金额</h2><div class="sp">{spark}</div></div>

<div class="sec"><h2><span class="n">1</span>按人排名</h2>{table('t_people', [('名次','名次','rk'),('销售员','销售员'),('大区','大区','reg'),('区域','区域'),('角色','角色'),('是否在职','在职','status'),('金额','金额(万)','big'),('占比','占比%','num'),('客户数','客户数','ctr'),('订单数','订单数','ctr'),('单均','单均(万)','num')], p)}</div>

<div class="sec"><h2><span class="n">2</span>按区域（大区）</h2>{table('t_regions', [('大区','大区','reg'),('金额','金额(万)','big'),('占比','占比%','num'),('人数','人数','ctr'),('客户数','客户数','ctr'),('订单数','订单数','ctr')], regs)}</div>

<div class="sec"><h2><span class="n">3</span>客户明细</h2>{table('t_custs', [('客户','客户'),('大区','大区','reg'),('主跟销售','主跟销售'),('金额','金额(万)','num'),('订单数','订单数','ctr')], custs)}</div>

<div class="sec"><h2><span class="n">4</span>主销货号</h2>{table('t_skus', [('货号','货号'),('物料名称','物料名称'),('金额','金额(万)','num'),('数量','数量','num'),('订单数','订单数','ctr')], skus)}</div>

<div class="sec"><h2><span class="n">核</span>查看原始记录</h2>
<div class="note">区间内本部门全部有效销售行 <b>{len(D['raw'])}</b> 行（口径见页脚），可自行核对到行。</div>
<div class="bar" style="margin-top:8px"><button class="btn p" onclick="openRaw()">打开原始记录（{len(D['raw'])} 行）</button></div></div>

<div class="foot">
<h3>口径说明</h3>
① <b>区间</b>：{D['start']} ~ {D['end']}，按 <code>合同日期</code> 取值；导出件合同日期最大 <code>{D['src_max_date']}</code>，区间端点已含。<br>
② <b>销售额口径</b>（与工作台管道 <code>脚本/erp_sales_core.py</code> 同源）：<code>{esc(D['caliber'])}</code>。<br>
③ <b>部门归属</b>：{esc(D['roster'])}。区域归并：区域名含「浙江」→浙江大区，含「广州/广东」→广州大区。<br>
④ <b>测试/赠品/预测单不计</b>：测试销售订单金额恒为 0；赠品行金额为 0；预测订单（FO）不计销售；宝锐系内部采购（宝锐/宝泰/横琴）已剔除。<br>
⑤ <b>勾稽</b>：Σ逐人 = Σ各大区 = Σ客户 = 部门合计 <b>{D['total']:,.4f} 万</b>（已程序化断言，逐项相等）。<br>
⑥ <b>量级提示</b>：科研为小额高频业务（对比官方《3、2026上半年月度数据-生命科学销售部.xlsx》1–6 月实际完成合计 238,034 元，本表 1–6 月同口径 26.17 万，同量级）。<br>
⑦ <b>本文件含真实客户名与金额，属内部资料</b>：仅本地留存，未上传工作台、未推 CDN。<br>
⑧ 离职人员（11 人）金额为 0 也占一行，便于看清岗位缺口。
</div>
</div>

<div id="modal" onclick="if(event.target.id==='modal')closeRaw()"><div id="mbox">
<div id="mhead"><h3>原始记录（{len(D['raw'])} 行）</h3>
<button class="btn" onclick="csvRaw()">导出 CSV</button>
<button class="btn" onclick="copyRaw()">复制 JSON</button>
<span class="note" id="copyok"></span>
<button class="btn p" style="margin-left:auto" onclick="closeRaw()">关闭</button></div>
<div id="mbody"></div></div></div>

<script>
var DATA = {json.dumps(D, ensure_ascii=False)};

/* ── 表格：筛选 + 排序 + CSV ── */
var SORT = {{}};
function cellText(tr, i) {{ return (tr.children[i] || {{}}).innerText || ''; }}
function numOf(s) {{ var m = String(s).replace(/,/g,'').match(/-?\\d+(\\.\\d+)?/); return m ? parseFloat(m[0]) : null; }}
function bindTable(tid) {{
  var t = document.getElementById(tid); if (!t) return;
  var ths = t.querySelectorAll('th');
  ths.forEach(function (th, i) {{
    th.onclick = function () {{
      SORT[tid] = SORT[tid] || {{}};
      var dir = SORT[tid].i === i && SORT[tid].dir === 'desc' ? 'asc' : 'desc';
      if (SORT[tid].i !== i) dir = 'desc';
      SORT[tid] = {{ i: i, dir: dir }};
      var tb = t.querySelector('tbody');
      var rows = Array.prototype.slice.call(tb.querySelectorAll('tr'));
      rows.sort(function (a, b) {{
        var x = cellText(a, i), y = cellText(b, i);
        var nx = numOf(x), ny = numOf(y);
        var c = (nx !== null && ny !== null) ? nx - ny : String(x).localeCompare(String(y), 'zh');
        return dir === 'desc' ? -c : c;
      }});
      rows.forEach(function (r) {{ tb.appendChild(r); }});
      ths.forEach(function (h) {{ h.removeAttribute('data-sort'); }});
      th.setAttribute('data-sort', dir);
    }};
  }});
  var inp = document.getElementById('q_' + tid);
  var n = document.getElementById('n_' + tid);
  function apply() {{
    var q = (inp.value || '').trim().toLowerCase();
    var tb = t.querySelector('tbody');
    var vis = 0;
    Array.prototype.forEach.call(tb.querySelectorAll('tr'), function (tr) {{
      var ok = !q || tr.innerText.toLowerCase().indexOf(q) >= 0;
      tr.style.display = ok ? '' : 'none';
      if (ok) vis++;
    }});
    if (n) n.textContent = vis + ' / ' + tb.querySelectorAll('tr').length + ' 行';
  }}
  var composing = false;
  inp.addEventListener('compositionstart', function () {{ composing = true; }});
  inp.addEventListener('compositionend', function () {{ composing = false; apply(); }});
  inp.addEventListener('input', function () {{ if (!composing) apply(); }});
  apply();
}}
function tableData(tid) {{
  var t = document.getElementById(tid);
  var rows = [];
  t.querySelectorAll('tbody tr').forEach(function (tr) {{
    if (tr.style.display === 'none') return;
    rows.push(Array.prototype.map.call(tr.children, function (td) {{ return td.innerText.trim(); }}));
  }});
  return rows;
}}
function csv(tid) {{
  var t = document.getElementById(tid);
  var heads = Array.prototype.map.call(t.querySelectorAll('thead th'), function (th) {{ return th.innerText.trim(); }});
  var lines = [heads.join(',')].concat(tableData(tid).map(function (r) {{
    return r.map(function (c) {{ return '"' + String(c).replace(/"/g, '""') + '"'; }}).join(',');
  }}));
  dl(lines.join('\\n'), tid + '.csv');
}}
function dl(text, name) {{
  var b = new Blob(['\\ufeff' + text], {{ type: 'text/csv;charset=utf-8' }});
  var a = document.createElement('a');
  a.href = URL.createObjectURL(b); a.download = name; a.click();
  setTimeout(function () {{ URL.revokeObjectURL(a.href); }}, 2000);
}}
/* ── 原始记录 ── */
function rawHtml() {{
  var ks = ['合同日期','单据编号','客户','销售员','大区','物料编码','物料名称','销售数量','价税合计'];
  var h = '<table><thead><tr>' + ks.map(function (k) {{ return '<th>' + k + '</th>'; }}).join('') + '</tr></thead><tbody>';
  DATA.raw.forEach(function (r) {{
    h += '<tr>' + ks.map(function (k) {{ return '<td class="l">' + (r[k] === undefined ? '' : r[k]) + '</td>'; }}).join('') + '</tr>';
  }});
  return h + '</tbody></table>';
}}
function openRaw() {{ document.getElementById('mbody').innerHTML = rawHtml(); document.getElementById('modal').classList.add('on'); }}
function closeRaw() {{ document.getElementById('modal').classList.remove('on'); }}
function csvRaw() {{
  var ks = ['合同日期','单据编号','客户','销售员','大区','物料编码','物料名称','销售数量','价税合计'];
  var lines = [ks.join(',')].concat(DATA.raw.map(function (r) {{
    return ks.map(function (k) {{ return '"' + String(r[k] === undefined ? '' : r[k]).replace(/"/g, '""') + '"'; }}).join(',');
  }}));
  dl(lines.join('\\n'), '原始记录.csv');
}}
function copyRaw() {{
  var s = JSON.stringify(DATA.raw, null, 1);
  var done = function () {{ document.getElementById('copyok').textContent = '已复制 ' + DATA.raw.length + ' 行'; }};
  if (navigator.clipboard) navigator.clipboard.writeText(s).then(done, function () {{ fallback(s, done); }});
  else fallback(s, done);
}}
function fallback(s, done) {{
  var ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select();
  try {{ document.execCommand('copy'); done(); }} catch (e) {{ alert('复制失败，请用导出 CSV'); }}
  document.body.removeChild(ta);
}}
['t_people','t_regions','t_custs','t_skus'].forEach(bindTable);
</script>
</body></html>
"""

html = BK.inject(html, theme='light', tag='营销中心 · 销售管理',
                 footer_extra='生命科学销售部（科研）销售额明细 ｜ %s ~ %s' % (D['start'], D['end']))
io.open(OUT, 'w', encoding='utf-8').write(html)

# ── 质检 ──
print('=== 质检 ===')
vr = BK.verify(html)
bad = [k for k, v in vr.items() if not v]
print(('✅' if not bad else '❌'), '品牌套件', '全 True' if not bad else bad)
ext = re.findall(r'(?:src|href)\s*=\s*["\'](https?:)?//', html)
print(('✅' if not ext else '❌'), '外链数 %d（应为 0）' % len(ext))
ph = re.findall(r'\{\{[A-Z_]+\}\}', html)
print(('✅' if not ph else '❌'), '遗留占位符 %d（应为 0）' % len(ph))
blocks = re.findall(r'<script[^>]*>(.*?)</script>', html, re.S)
fail = 0
for i, b in enumerate(blocks, 1):
    fd, p = tempfile.mkstemp(suffix='.js')
    os.write(fd, b.encode())
    os.close(fd)
    r = subprocess.run(['node', '--check', p], capture_output=True, text=True)
    if r.returncode != 0:
        fail += 1
        print('   ❌ script#%d: %s' % (i, r.stderr.strip().splitlines()[-1][:110]))
    os.unlink(p)
print(('✅' if fail == 0 else '❌'), 'node --check：%d 个 script 块，%d 个报错' % (len(blocks), fail))
print('  表格数据行：按人 %d ｜ 大区 %d ｜ 客户 %d ｜ 货号 %d ｜ 原始 %d'
      % (len(D['people']), len(D['regions']), len(D['customers']), len(D['skus']), len(D['raw'])))
print('\n输出 %s（%.0f KB）' % (OUT, os.path.getsize(OUT) / 1024.0))
sys.exit(0 if (not bad and not ext and not ph and fail == 0) else 1)
