#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""2026Q3 汇报 · 独立数据台（本地单文件 HTML，口令门，不上工作台）。

数据源：数据/2026Q3汇报_汇总.json（由 脚本/build_q3_data.py 生成）
产物  ：经营分析/2026Q3汇报数据台.html
口径  ：只渲染原表数据，不做换算；金额单位随原表；零外链；文字列不截断。
"""
import hashlib
import json
import os
import re
from collections import Counter, defaultdict

ROOT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
SRC = os.path.join(ROOT, '数据', '2026Q3汇报_汇总.json')
OUT = os.path.join(ROOT, '经营分析', '2026Q3汇报数据台.html')
PASSCODE = 'biori2026'          # 访问口令（改这里即可；哈希内嵌，明文不落页面）

NAMES = ['陈溪子', '杨天雄', '路要峰', '黄文才', '林潇', '李江辉', '张文蝶', '宋学伟', '冯贤平',
         '刘虹晶', '刘子研', '赵云浩', '徐卓凡',
         '黄建华', '王诗香', '陈彩红', '叶信福', '侯海瑜', '袁殿果', '吴云', '韩远怀']
DEPT = {'陈溪子': '销售拓展部', '杨天雄': '销售拓展部', '路要峰': '销售拓展部', '黄文才': '销售拓展部',
        '林潇': '销售拓展部', '李江辉': '销售拓展部', '张文蝶': '销售拓展部', '宋学伟': '销售拓展部',
        '冯贤平': '销售拓展部', '刘虹晶': '销售拓展部', '刘子研': '大客户部', '赵云浩': '大客户部',
        '徐卓凡': '销售拓展部',
        '黄建华': '生命科学部', '王诗香': '生命科学部', '陈彩红': '生命科学部', '叶信福': '生命科学部',
        '侯海瑜': '生命科学部', '袁殿果': '生命科学部', '吴云': '生命科学部', '韩远怀': '生命科学部'}

# 本轮应报送名单（2026Q3：大客户部 2 + 销售拓展部 11 + 生命科学销售部 8 = 21）
REPORTERS = ['陈溪子', '黄文才', '杨天雄', '路要峰', '刘虹晶', '冯贤平', '林潇', '李江辉', '张文蝶',
             '宋学伟', '徐卓凡', '刘子研', '赵云浩',
             '黄建华', '王诗香', '陈彩红', '叶信福', '侯海瑜', '袁殿果', '吴云', '韩远怀']


REGION_WHO = {'广东区': '韩远怀', '浙江': '吴云'}   # 源文件按区域命名（如 …-2026销售目标及开发计划-广东区__原表.xlsx）


def person(s):
    for n in NAMES:
        if n in str(s):
            return n
    for k, v in REGION_WHO.items():
        if k in str(s):
            return v
    return str(s)


def num(v):
    try:
        return float(str(v).replace(',', '').replace('%', '').strip())
    except Exception:
        return None


def wan(v):
    return '' if v is None else round(v / 10000.0, 2)


def main():
    D = json.load(open(SRC, encoding='utf-8'))
    T = D['tables']
    ERP_JSON = os.path.join(os.path.dirname(SRC), '2026Q3_ERP对账.json')
    E = json.load(open(ERP_JSON, encoding='utf-8')) if os.path.exists(ERP_JSON) else None

    opp = []
    for r in T['商机明细']:
        p = person(r.get('销售') or r.get('_汇报人') or '')
        opp.append({
            '汇报人': p, '部门': DEPT.get(p, r.get('部门') or '—'), '序号': r.get('序号') or '',
            '集团客户': r.get('集团客户') or '', '客户名称': r.get('客户名称') or '',
            '商机名称': r.get('商机名称') or '', '性质': r.get('商机性质') or r.get('2026新增') or '',
            '背景': r.get('商机背景概述') or '', '资源需求': r.get('资源需求') or '',
            '时间节点': r.get('关键时间节点') or '', '异常复盘': r.get('前三季度异常原因复盘') or r.get('三季度异常原因复盘') or '',
            '四季度难点': r.get('四季度冲刺难点及措施') or r.get('四季度目标保障难点及措施') or '',
            '业务类型': r.get('业务类型') or '',
            'y2024': r.get('2024年销售额') or '', 'y2025': r.get('2025年销售额') or r.get('25年销售额') or '',
            'y2026': r.get('2026年销售额') or r.get('26年销售额') or '',
            '目标': r.get('2026目标') or '', '达成率': r.get('2026年度达成%') or '',
            'q1p': r.get('1季度\n计划') or '', 'q1a': r.get('1季度\n实绩') or '',
            'q2p': r.get('2季度\n计划') or '', 'q2a': r.get('2季度\n实绩') or '',
            'q3p': r.get('3季度\n计划') or '', 'q3a': r.get('3季度\n实绩') or '',
            'q4p': r.get('4季度\n计划') or '', 'q4a': r.get('4季度\n实绩') or '',
            '商机ID': r.get('商机ID') or '', '来源': r.get('_来源') or '',
        })

    ov = []
    for r in T['逾期应收明细']:
        if not (r.get('往来单位') or r.get('单据编号')):
            continue
        ov.append({'销售员': person(r.get('销售员') or ''), '部门': r.get('销售部门') or '',
                   '往来单位': r.get('往来单位') or '', '单据编号': r.get('单据编号') or '',
                   '合同编号': r.get('合同编号') or '', '业务日期': (r.get('业务日期') or '')[:10],
                   '到期日': (r.get('到期日') or '')[:10], '逾期天数': r.get('逾期天数') or '',
                   '逾期金额': r.get('逾期金额') or '', '未逾期天数': r.get('未逾期天数') or '',
                   '未逾期金额': r.get('未逾期金额') or '', '10月回款进度': r.get('10月回款进度') or '',
                   '来源': r.get('_来源') or ''})

    cust = []
    cust_drop = 0
    for r in T['客户季度分析']:
        if not r.get('客户名称'):
            cust_drop += 1
            continue
        cust.append({'汇报人': person(r.get('_汇报人') or ''), '集团': r.get('集团名称') or '',
                     '客户名称': r.get('客户名称') or '', '年份': r.get('年份') or '',
                     'q1': r.get('1季度') or '', 'q2': r.get('2季度') or '', 'q3': r.get('3季度') or '',
                     'q4': r.get('4季度') or '', '合计': r.get('合计') or '', '同比': r.get('同比') or '',
                     '原因': r.get('原因分析') or ''})

    mon = []
    for r in T['个人月度销售额']:
        p = person(r.get('汇报人') or r.get('来源文件') or '')
        mon.append({'汇报人': p, '月份': r.get('月份'), 'v2026': num(r.get('2026')) if '2026' in r else num(r.get('数值')),
                    'v2025': num(r.get('2025')) if '2025' in r else None, '来源': r.get('来源文件') or ''})

    # ── 聚合 ─────────────────────────────────────────────
    ov_amt = defaultdict(float)
    ov_cnt = Counter()
    for r in ov:
        a = num(r['逾期金额']) or 0
        ov_amt[r['销售员']] += a
        ov_cnt[r['销售员']] += 1
    cnt_by_person = Counter(r['汇报人'] for r in opp)
    tgt, ach = defaultdict(float), defaultdict(float)
    for r in opp:
        t = num(r['目标'])
        if t:
            tgt[r['汇报人']] += t
        for k in ('q1a', 'q2a', 'q3a', 'q4a'):
            v = num(r[k])
            if v:
                ach[r['汇报人']] += v
    kpi = {
        '汇报份数': len(set(REPORTERS) | set(cnt_by_person)), '覆盖人数': len(set(REPORTERS) | set(cnt_by_person)),
        '商机条数': len(opp), '商机客户数': len({r['客户名称'] for r in opp if r['客户名称']}),
        '逾期笔数': len(ov), '逾期金额合计': round(sum(ov_amt.values()), 2),
        '逾期客户数': len({r['往来单位'] for r in ov if r['往来单位']}),
        '2026目标合计_万': round(sum(tgt.values()) / 10000.0, 1),
        '季度实绩合计_万': round(sum(ach.values()) / 10000.0, 1),
    }
    # REPORTERS 已在模块级定义（21 人）
    erp_bp = {}
    if E:
        for _r in E['rows']:
            if _r.get('ERP实绩_万') is not None:
                erp_bp[_r['人']] = _r
    per_person = []
    for p in sorted(set(list(cnt_by_person) + list(ov_amt) + list(tgt) + REPORTERS),
                    key=lambda x: (-cnt_by_person.get(x, 0), REPORTERS.index(x) if x in REPORTERS else 99)):
        per_person.append({'人': p, '部门': DEPT.get(p, '—'), '商机数': cnt_by_person.get(p, 0),
                           '2026目标_万': round(tgt.get(p, 0) / 10000.0, 2),
                           '季度实绩_万': round(ach.get(p, 0) / 10000.0, 2),
                           '逾期笔数': ov_cnt.get(p, 0), '逾期金额': round(ov_amt.get(p, 0), 2),
                           'ERP实绩_万': (erp_bp.get(p) or {}).get('ERP实绩_万'),
                           'ERP差额_万': round(round(ach.get(p, 0) / 10000.0, 2) - (erp_bp.get(p) or {}).get('ERP实绩_万'), 2)
                           if (erp_bp.get(p) or {}).get('ERP实绩_万') is not None and ach.get(p, 0) > 0 else None,
                           '备注': '商机表为空模板（未填内容）' if cnt_by_person.get(p, 0) == 0 else ''})

    gaps = D.get('缺口') or {}

    # ── 逾期分档（按人 × 账龄档）────────────────────────────
    BUCKETS = [(0, 30, '≤30天'), (31, 60, '31–60天'), (61, 90, '61–90天'),
               (91, 180, '91–180天'), (181, 10 ** 9, '>180天')]
    buck = defaultdict(lambda: defaultdict(lambda: [0, 0.0]))
    bucket_rows = []
    for r in ov:
        a = num(r['逾期金额']) or 0
        if a <= 0:
            continue
        d = num(r['逾期天数'])
        lab = '未标天数' if d is None else next((l for lo, hi, l in BUCKETS if lo <= d <= hi), '未标天数')
        buck[r['销售员']][lab][0] += 1
        buck[r['销售员']][lab][1] += a
        bucket_rows.append(dict(r, 档位=lab))
    bucket_matrix = []
    for p, m in sorted(buck.items(), key=lambda kv: -sum(v[1] for v in kv[1].values())):
        row = {'人': p, '部门': DEPT.get(p, '—'), '合计笔数': sum(v[0] for v in m.values()),
               '合计金额': round(sum(v[1] for v in m.values()), 2)}
        for _, _, l in BUCKETS:
            row[l + '_笔'] = m.get(l, [0, 0])[0]
            row[l + '_额'] = round(m.get(l, [0, 0])[1], 2)
        bucket_matrix.append(row)

    # ── 四季度难点 / 异常复盘 关键词聚类（规则法，可溯源，不用模型编）──
    KW = [('价格/降价压力', ['价格', '报价', '降价', '低价', '压价', '预算', '成本', '让价', '折扣']),
          ('竞品/替换风险', ['竞品', '诺唯赞', '翌圣', '菲鹏', '迈迪安', '宝创', '致善', '替代', '对比', 'PK']),
          ('质量/性能问题', ['批间差', '稳定性', '沉淀', '漏检', '灵敏度', '非特异', '起线', '析晶', '污染']),
          ('供货/交付', ['交期', '备货', '发货', '供货', '库存', '交付']),
          ('客户预算/立项', ['立项', '预算', '审批', '采购计划', '暂停', '停项', '项目变更', '立项']),
          ('注册/合规', ['注册', '备案', '报证', '法规', '临床', '注册变更']),
          ('决策链/关系', ['决策', '话事人', '采购流程', '换供应商', '关键人', '关系']),
          ('测试资源/窗口', ['测试', '送样', '验证', '窗口', '排队', '试用'])]
    cl = defaultdict(lambda: {'条数': 0, '人': Counter(), '样例': []})
    for r in opp:
        txt = (r['四季度难点'] or '') + ' ' + (r['异常复盘'] or '')
        if not txt.strip():
            continue
        for cat, kws in KW:
            if any(k in txt for k in kws):
                c = cl[cat]
                c['条数'] += 1
                c['人'][r['汇报人']] += 1
                if len(c['样例']) < 6:
                    c['样例'].append({'人': r['汇报人'], '客户': r['客户名称'],
                                      '原文': re.sub(r'\s+', ' ', txt)})
    clusters = [{'类别': k, '条数': v['条数'], '人数': len(v['人']),
                 '涉及人': '、'.join('%s(%d)' % (a, b) for a, b in v['人'].most_common(6)),
                 '样例': v['样例']} for k, v in sorted(cl.items(), key=lambda kv: -kv[1]['条数'])]
    # ── 生命科学部三张表（本轮新增：8 人逐人 PPT 的 目标/课题组/四季度预测）──
    ls_target = []
    for r in T.get('生命科学_月度目标', []):
        p = person(r.get('汇报人') or r.get('来源文件') or '')
        ls_target.append({'人': p, '部门': DEPT.get(p, '生命科学部'), '月份': r.get('月份'),
                          '销售目标': r.get('销售目标') or '', '实际完成': r.get('实际完成') or '',
                          '完成率': r.get('完成率') or '', '来源': r.get('来源文件') or ''})
    ls_kt = []
    for r in T.get('生命科学_课题组', []):
        p = person(r.get('汇报人') or r.get('来源文件') or '')
        ls_kt.append({'人': p, '部门': DEPT.get(p, '生命科学部'), '学校': r.get('学校') or '',
                      '名称': (r.get('名称') or '').replace('\n', ' '), '商机状态': r.get('商机状态') or '',
                      '意向产品': r.get('意向产品') or '', '决策人': r.get('决策人') or '',
                      '课题组模式': r.get('课题组模式') or '', '年度目标_w': r.get('年度目标（w）') or '',
                      '合作产品': r.get('合作产品') or '', '痛点': r.get('痛点') or '',
                      '研究方向': r.get('导师研究方向') or '', '上半年计划': r.get('上半年开发计划') or '',
                      '下半年难点': r.get('下半年目标保障难点及措施') or '', '来源': r.get('来源文件') or ''})
    ls_fc = []
    for r in T.get('生命科学_四季度预测', []):
        p = person(r.get('汇报人') or r.get('来源文件') or '')
        ls_fc.append({'人': p, '部门': DEPT.get(p, '生命科学部'), '客户名称': r.get('客户名称') or '',
                      '课题组': (r.get('课题组') or '').replace('\n', ' '), '研究方向': r.get('研究方向') or '',
                      '人数': r.get('课题组人数') or '', '采购模式': r.get('采购模式') or '',
                      '重点任务': r.get('当前重点任务') or '', '合计': r.get('合计') or '',
                      '上半年完成': r.get('上半年完成') or '', 'q3达成': r.get('3季度达成') or '',
                      'q4预测': r.get('4季度预测') or '', 'q4达成': r.get('4季度达成') or '',
                      '全年完成': r.get('全年完成') or '', '来源': r.get('来源文件') or ''})

    # ── 生命科学部补充：产品线构成 + 市场扫描（课题组竞品在用）──
    LS_PROD_KEYS = ['高保真PCR', '同源重组酶', '小提/大提', 'RT逆转录', 'Q荧光定量', '普通PCR',
                    '内切酶', 'ECL发光液', '蛋白marker', '蛋白胶', '蛋白定量', 'PVDF']
    mkt = []
    for r in T.get('生命科学_市场扫描', []):
        rec = {'人': person(r.get('汇报人') or ''), '单位': r.get('单位') or '',
               '位置': r.get('位置') or '', '课题组': r.get('课题组') or ''}
        for k in LS_PROD_KEYS:
            if r.get(k):
                rec[k] = r[k]
        mkt.append(rec)
    prod_mix = [{'人': person(r.get('汇报人') or ''), '产品线': r.get('产品线') or '',
                 '销售额': r.get('销售额') or '', '占比': r.get('占比') or ''}
                for r in T.get('生命科学_产品线构成', [])]
    _agg = {}
    for r in mkt:
        for k in LS_PROD_KEYS:
            v = str(r.get(k) or '').strip()
            if v:
                _agg.setdefault(k, {})
                _agg[k][v] = _agg[k].get(v, 0) + 1
    market_agg = []
    for k, v in _agg.items():
        tot = sum(v.values())
        market_agg.append({'产品线': k, '在用课题组数': tot,
                           '诺唯赞占比': round(v.get('诺唯赞', 0) / tot * 100, 1) if tot else 0,
                           '品牌构成': '、'.join('%s %d' % (b, c) for b, c in sorted(v.items(), key=lambda kv: -kv[1])[:8])})
    market_agg.sort(key=lambda r: -r['在用课题组数'])

    # 大客户部专项两张表（本轮补录：刘子研「个人业绩完成情况」+ 客户同比归因）
    big_per = [{'人': person(r.get('汇报人') or ''), '年度目标': r.get('年度目标（万元）'),
                '月度实绩': r.get('月度实绩') or '', '月度合计': r.get('月度合计（万元）'),
                '剩余目标': r.get('2026年剩余目标（万元）'), '已完成': r.get('已完成（万元）'),
                '完成率': r.get('完成率')} for r in T.get('大客户部_个人业绩', [])]
    big_attr = [{'人': person(r.get('汇报人') or ''), '客户': r.get('客户') or '',
                 '原因': r.get('原因分析') or '', '货号': r.get('重点货号') or '',
                 '备注': r.get('备注') or ''} for r in T.get('大客户部_客户同比归因', [])]

    payload = {'erp': E, 'kpi': kpi, 'perPerson': per_person, 'opp': opp, 'overdue': ov, 'cust': cust,
               'custDrop': cust_drop, 'bigPer': big_per, 'bigAttr': big_attr,
               'marketScan': mkt, 'prodMix': prod_mix, 'marketAgg': market_agg,
               'month': mon, 'gaps': gaps, 'inv': D['inventory'], 'buckets': bucket_matrix,
               'bucketRows': bucket_rows, 'clusters': clusters,
               'lsTarget': ls_target, 'lsKT': ls_kt, 'lsFC': ls_fc,
               'meta': D['meta'], 'counts': D['计数']}
    js = json.dumps(payload, ensure_ascii=False)
    gate = hashlib.sha256(PASSCODE.encode()).hexdigest()

    html = TEMPLATE.replace('__DATA__', js).replace('__GATE__', gate).replace(
        '__GEN__', D['meta']['生成']).replace('__SRC__', D['meta']['来源'])
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    open(OUT, 'w', encoding='utf-8').write(html)
    print('KPI：%s' % json.dumps(kpi, ensure_ascii=False))
    print('按人：%s' % json.dumps(per_person, ensure_ascii=False)[:600])
    print('内容：商机 %d ｜ 逾期 %d ｜ 客户季度 %d ｜ 月度 %d' % (len(opp), len(ov), len(cust), len(mon)))
    print('→ %s（%.1f KB）' % (OUT, os.path.getsize(OUT) / 1024.0))


TEMPLATE = r'''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>宝锐生物 · 2026 Q3 销售汇报数据台（机密）</title>
<style>
:root{--pri:#1F4E9C;--pri-d:#123A78;--ink:#1B2A41;--sub:#5F6B7C;--line:#E3EAF3;--bg:#F4F7FB;
      --up:#0F7A44;--down:#B3261E;--warn:#B25A00}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13.5px/1.6 "PingFang SC","Microsoft YaHei",system-ui,sans-serif}
.top{background:linear-gradient(120deg,#0E3D7A,#1F4E9C 55%,#2F7BC4);color:#fff;padding:14px 22px;
     display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap}
.brand b{font-size:17px;letter-spacing:1.2px}
.brand small{display:block;font-size:11px;letter-spacing:1.6px;opacity:.85;margin-top:2px}
.top .r{font-size:11.5px;opacity:.92;text-align:right;line-height:1.7}
.wrap{max-width:1560px;margin:16px auto 40px;padding:0 16px}
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.tab{border:1px solid var(--line);background:#fff;border-radius:9px 9px 0 0;padding:7px 14px;font-weight:700;
     color:var(--sub);cursor:pointer;font-size:12.5px}
.tab.on{background:var(--pri);border-color:var(--pri);color:#fff}
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-bottom:14px;
      box-shadow:0 1px 3px rgba(16,42,86,.06)}
.card h2{font-size:15px;margin:0 0 10px;color:var(--pri-d);display:flex;align-items:center;gap:8px}
.card h2::before{content:"";width:4px;height:15px;border-radius:2px;background:var(--pri)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:10px}
.kpi{background:linear-gradient(160deg,#F8FBFF,#EDF4FB);border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.kpi .k{font-size:11.5px;color:var(--sub)}
.kpi .v{font-size:20px;font-weight:800;color:var(--pri-d);margin-top:4px;font-variant-numeric:tabular-nums}
.kpi .v i{font-size:11.5px;font-weight:600;font-style:normal;color:var(--sub);margin-left:3px}
.tools{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:9px}
.tools input,.tools select{border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12.5px;
      font-family:inherit;background:#fff;color:var(--ink)}
.tools input{min-width:210px}
.tools button{border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 12px;font-size:12.5px;
      cursor:pointer;color:#33475b;font-family:inherit;font-weight:600}
.tools button:hover{border-color:var(--pri);color:var(--pri)}
.tw{overflow-x:auto;border:1px solid var(--line);border-radius:10px}
table{width:100%;border-collapse:collapse;table-layout:fixed}
th,td{padding:7px 9px;border-bottom:1px solid #F1F4F9;font-size:12px;text-align:left;vertical-align:top;
      white-space:normal;overflow-wrap:break-word;word-break:normal}
th{background:#F7FAFF;color:var(--sub);font-weight:700;position:sticky;top:0;cursor:pointer;user-select:none}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td.num{white-space:nowrap}
th.num{white-space:normal}   /* 表头一律可换行：防窄数值列里表头被挤成两截 */
td.txt,th.txt{white-space:normal;overflow-wrap:break-word}
td.long,th.long{white-space:normal;overflow-wrap:break-word;line-height:1.62;font-size:11.5px;color:#33475b}
td.long{background:#FCFDFF;box-shadow:inset 1px 0 0 #F2F6FC,inset -1px 0 0 #F2F6FC}
tbody tr:hover{background:#F6FAFF}
tbody tr:hover td.long{background:#F4F9FF}
.pill{display:inline-block;border-radius:8px;padding:0 7px;font-size:11px;font-weight:700;line-height:17px}
.p-on{background:#EAF7EF;color:var(--up)}.p-off{background:#FDEEEC;color:var(--down)}
.p-gray{background:#F1F4F9;color:var(--sub)}
.muted{color:var(--sub);font-size:11.5px}
.gate{position:fixed;inset:0;background:linear-gradient(140deg,#0E3D7A,#1F4E9C);display:flex;align-items:center;
      justify-content:center;z-index:99}
.gate .box{background:#fff;border-radius:14px;padding:26px 28px;width:340px;text-align:center}
.gate h3{margin:0 0 6px;color:var(--pri-d)}
.gate p{margin:0 0 14px;font-size:12px;color:var(--sub)}
.gate input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;font-size:13px;font-family:inherit}
.gate button{margin-top:12px;width:100%;padding:10px;border:0;border-radius:9px;background:var(--pri);color:#fff;
      font-size:13.5px;font-weight:700;cursor:pointer;font-family:inherit}
.gate .err{color:var(--down);font-size:12px;margin-top:8px;min-height:16px}
.wm{position:fixed;right:14px;bottom:10px;font-size:11px;color:#9AA5B4;z-index:5}
.foot{margin-top:14px;font-size:11.5px;color:#8A94A6;text-align:center;line-height:1.8}
.bar{height:9px;border-radius:5px;background:#EDF2F9;overflow:hidden;margin-top:3px}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#2F7BC4,#1F4E9C)}
.hint{font-size:11.5px;color:var(--warn);background:#FFF8F1;border:1px solid #F3D9B8;border-radius:8px;
      padding:7px 11px;margin-bottom:10px}
</style></head>
<body>
<div class="gate" id="gate"><div class="box">
  <h3>🔒 机密数据台</h3>
  <p>本页为内部经营数据，仅限本人查看<br>未部署到工作台 · 请勿转发</p>
  <input id="pw" type="password" placeholder="输入访问口令" autocomplete="off">
  <button id="go">进入</button>
  <div class="err" id="err"></div>
</div></div>

<div class="top">
  <div class="brand"><b>BIORI BIOTECH 宝锐生物</b><small>2026 Q3 销售汇报 · 数据台（机密）</small></div>
  <div class="r">数据源：__SRC__<br>生成：__GEN__　｜　本地文件 · 未上工作台</div>
</div>

<div class="wrap">
  <div class="tabs" id="tabs"></div>
  <div id="view"></div>
  <div class="foot">守护生命健康，缔造美好生活　｜　BIORI BIOTECH · 宝锐生物　｜　本页仅本地留存，未部署</div>
</div>
<div class="wm">机密 · 仅限本人 · 2026Q3</div>

<script>
const D = __DATA__;
const GATE = "__GATE__";
const P = ['kpi','perPerson','opp','overdue','cust','month','gaps','inv','meta','counts','lsTarget','lsKT','lsFC'];
let tab = '总览', sortKey = null, sortDir = -1, q = '', who = '';

const el = (h) => { const d = document.createElement('div'); d.innerHTML = h.trim(); return d.firstChild; };
const esc = (s) => String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const n2 = (v) => (v==='' || v==null || isNaN(Number(v))) ? esc(v) : Number(v).toLocaleString('zh-CN', {maximumFractionDigits:2});
// ── 列类型推断 + 列宽分配（长文本列自动获得更宽列位并换行；数值列窄 + 右对齐 + 等宽数字）──
function inferTypes(list, cols){
  return cols.map(c => {
    const vals = list.slice(0, 120).map(r => r[c[0]]).filter(v => v != null && String(v).trim() !== '');
    if (!vals.length) return 'txt';
    if (vals.every(v => !isNaN(Number(String(v).replace(/,/g, ''))))) return 'num';
    const lens = vals.map(v => String(v).length);
    const mx = Math.max.apply(null, lens);
    const avg = lens.reduce((a, b) => a + b, 0) / lens.length;
    return (mx >= 36 || avg >= 14) ? 'long' : 'txt';
  });
}
function colgroup(types){
  const base = types.map(t => t === 'num' ? 9 : (t === 'long' ? 17 : 12));
  const sum = base.reduce((a, b) => a + b, 0) || 1;
  return '<colgroup>' + base.map(w => '<col style="width:' + (w / sum * 100).toFixed(2) + '%">').join('') + '</colgroup>';
}
function wideAttr(n){
  return n >= 12 ? ' style="min-width:1440px"' : '';   /* 宽表给最小宽度，列不被挤成竖条 */
}
function mkTable(types, head, body){
  return '<table' + wideAttr(types.length) + '>' + colgroup(types) + '<thead><tr>' +
    head.map((h, i) => '<th class="' + types[i] + '">' + h + '</th>').join('') +
    '</tr></thead><tbody>' + body + '</tbody></table>';
}
const TAG = {总览:1, ERP对账:1, 商机明细:1, 逾期应收:1, 逾期分档:1, 难点聚类:1, 生命科学部:1, 客户季度:1, 月度销售额:1, 口径与数据源:1};

function render(){
  const t = document.getElementById('tabs');
  t.innerHTML = Object.keys(TAG).map(k => '<div class="tab'+(k===tab?' on':'')+'" data-t="'+k+'">'+k+'</div>').join('');
  t.querySelectorAll('.tab').forEach(b => b.onclick = () => { tab = b.dataset.t; sortKey=null; render(); });
  const v = document.getElementById('view');
  v.innerHTML = ({总览:viewKpi, ERP对账:viewErp, 商机明细:viewOpp, 逾期应收:viewOv, 逾期分档:viewBuckets,
                  难点聚类:viewClusters, 生命科学部:viewLS, 客户季度:viewCust,
                  月度销售额:viewMon, 口径与数据源:viewGaps}[tab])();
  bind();
}

function viewKpi(){
  const k = D.kpi;
  const cards = [['汇报份数', k['汇报份数'], '份'], ['覆盖销售员', k['覆盖人数'], '人'],
                 ['商机条数', k['商机条数'], '条'], ['涉及客户', k['商机客户数'], '家'],
                 ['逾期笔数', k['逾期笔数'], '笔'], ['逾期金额', n2(k['逾期金额合计']), '元'],
                 ['2026 目标合计', k['2026目标合计_万'], '万'], ['季度实绩合计', k['季度实绩合计_万'], '万']];
  let h = '<div class="card"><h2>总览</h2><div class="kpis">' + cards.map(c =>
      '<div class="kpi"><div class="k">'+c[0]+'</div><div class="v">'+c[1]+'<i>'+c[2]+'</i></div></div>').join('') + '</div></div>';
  h += '<div class="card"><h2>按人汇总（商机 / 目标 / 实绩 / 逾期）</h2><div class="tw"><table><thead><tr>' +
       ['销售员','部门','商机数','2026目标(万)','报告实绩(万)','ERP实绩(万)','差额(万)','逾期笔数','逾期金额(元)','备注'].map((c,i)=>
        '<th class="'+(i>1&&i<9?'num':'')+'">'+c+'</th>').join('') + '</tr></thead><tbody>' +
       D.perPerson.map(r => '<tr><td>'+esc(r['人'])+'</td><td>'+esc(r['部门'])+'</td>' +
         '<td class="num">'+r['商机数']+'</td><td class="num">'+n2(r['2026目标_万'])+'</td>' +
         '<td class="num">'+n2(r['季度实绩_万'])+'</td>' +
         '<td class="num">'+(r['ERP实绩_万']==null?'—':n2(r['ERP实绩_万']))+'</td>' +
         '<td class="num" style="color:'+(r['ERP差额_万']!=null&&r['ERP差额_万']<0?'#c0392b':'#2c7')+'">' +
         (r['ERP差额_万']==null||r['ERP差额_万']==0?'—':(r['ERP差额_万']>0?'+':'')+n2(r['ERP差额_万']))+'</td>' +
         '<td class="num">'+r['逾期笔数']+'</td>' +
         '<td class="num">'+n2(r['逾期金额'])+'</td><td class="muted">'+esc(r['备注']||'')+'</td></tr>').join('') +
       '</tbody></table></div><div class="muted" style="margin-top:6px">口径提示：逾期数据来自「附表_逾期&未逾期（汇总）」，' +
       '系公司级模板但本次仅销售拓展部填写，故大客户部（刘子研/赵云浩）显示 0 笔不代表无逾期。</div></div>';
  return h;
}

function rows(list, cols, keyer){
  let d = list.slice();
  if (q) d = d.filter(r => JSON.stringify(r).toLowerCase().includes(q.toLowerCase()));
  if (who) d = d.filter(r => keyer(r) === who);
  if (sortKey) d.sort((a,b) => {
    const x = a[sortKey], y = b[sortKey];
    const nx = Number(String(x).replace(/,/g,'')), ny = Number(String(y).replace(/,/g,''));
    const c = (!isNaN(nx) && !isNaN(ny) && x!=='' && y!=='') ? nx-ny : String(x).localeCompare(String(y),'zh');
    return c * sortDir;
  });
  return d;
}

function toolbar(people, extra){
  return '<div class="tools">' +
    '<input id="q" placeholder="搜索（客户 / 商机 / 单据…）" value="'+esc(q)+'">' +
    '<select id="who"><option value="">全部销售员</option>' +
      people.map(p => '<option'+(p===who?' selected':'')+'>'+esc(p)+'</option>').join('') + '</select>' +
    (extra||'') +
    '<button id="csv">导出当前表 CSV</button>' +
    '<button id="clr">清除筛选</button>' +
    '<span class="muted" id="cc"></span></div>';
}

const OPP_COLS = [['汇报人','销售'],['客户名称','客户'],['商机名称','商机'],['性质','性质'],['业务类型','业务类型'],
  ['y2025','25销售额'],['目标','26目标'],['达成率','达成%'],['q3a','3季实绩'],['q4p','4季计划'],
  ['时间节点','关键节点'],['资源需求','资源需求'],['异常复盘','前三季度异常复盘'],['四季度难点','四季度难点'],['商机ID','商机ID']];
const OV_COLS = [['销售员','销售员'],['往来单位','往来单位'],['单据编号','单据编号'],['合同编号','合同编号'],
  ['业务日期','业务日期'],['到期日','到期日'],['逾期天数','逾期天数'],['逾期金额','逾期金额'],
  ['未逾期金额','未逾期金额'],['10月回款进度','10月回款进度']];

function tableOf(list, cols, people, keyer){
  const d = rows(list, cols, keyer);
  window.__cur = {rows:d, cols:cols};
  const types = inferTypes(d.length ? d : list, cols);
  let h = '<div class="card">' + toolbar(people) + '<div class="tw"><table>' + wideAttr(cols.length) + colgroup(types) + '<thead><tr>' +
    cols.map((c,i) => '<th class="'+types[i]+'" data-k="'+c[0]+'">'+c[1]+
      (sortKey===c[0] ? (sortDir>0?' ▲':' ▼') : '')+'</th>').join('') + '</tr></thead><tbody>' +
    d.map(r => '<tr>' + cols.map((c,i) => '<td class="'+types[i]+'">'+n2(r[c[0]])+'</td>').join('') + '</tr>').join('') +
    '</tbody></table></div><div class="muted" style="margin-top:6px">共 '+d.length+' 行（原始 '+list.length+' 行）｜ 点击表头排序</div></div>';
  return h;
}

function viewOpp(){ return tableOf(D.opp, OPP_COLS, D.perPerson.map(r=>r['人']), r=>r['汇报人']); }
function viewOv(){ return tableOf(D.overdue, OV_COLS, D.perPerson.map(r=>r['人']), r=>r['销售员']); }
function viewCust(){
  let h = tableOf(D.cust, [['汇报人','汇报人'],['客户名称','客户'],['年份','年份'],['q1','1季'],
    ['q2','2季'],['q3','3季'],['q4','4季'],['合计','合计'],['同比','同比'],['原因','原因分析']],
    D.perPerson.map(r=>r['人']), r=>r['汇报人']) +
    (D.custDrop ? '<div class="muted" style="margin-top:-6px">原表另有 '+D.custDrop+' 行无客户名称（原表小计/合计行或空行），未计入上表；如需核对请查原表。</div>' : '');
  const pct = (v) => (v==='' || v==null || isNaN(Number(v))) ? '—' : (Number(v)*100).toFixed(2)+'%';
  const bp = D.bigPer || [], ba = D.bigAttr || [];
  if (bp.length) {
    h += '<div class="card"><h2>大客户部 · 个人业绩完成情况（原表直读）</h2>' +
      '<div class="muted">来源：'+esc(bp[0]['人'])+' PPT 内嵌「个人业绩完成情况」表；单位随原表（万元），未做换算；同表内「月度合计」与「已完成」为原表并列的两套口径，均按原值呈现。</div>' +
      '<div class="tw">' + mkTable(['txt','num','long','num','num','num','num'],
        ['销售员','年度目标(万)','月度实绩（原表序列）','月度合计(万)','剩余目标(万)','已完成(万)','完成率'],
        bp.map(r => '<tr><td>'+esc(r['人'])+'</td><td class="num">'+n2(r['年度目标'])+'</td>' +
          '<td class="long">'+esc(r['月度实绩'])+'</td><td class="num">'+n2(r['月度合计'])+'</td>' +
          '<td class="num">'+n2(r['剩余目标'])+'</td><td class="num">'+n2(r['已完成'])+'</td>' +
          '<td class="num">'+pct(r['完成率'])+'</td></tr>').join('')) + '</div></div>';
  }
  if (ba.length) {
    h += '<div class="card"><h2>大客户部 · 客户同比归因与重点货号（'+ba.length+' 条）</h2>' +
      '<div class="muted">来源：同上内嵌表；原因分析与货号为原表摘录、未改写；含金额影响的原表表述。</div>' +
      '<div class="tw">' + mkTable(['txt','txt','long','long','long'],
        ['销售员','客户','同比原因分析','重点货号','备注'],
        ba.map(r => '<tr><td>'+esc(r['人'])+'</td><td>'+esc(r['客户'])+'</td>' +
          '<td class="long">'+esc(r['原因'])+'</td><td class="long">'+esc(r['货号'])+'</td>' +
          '<td class="long">'+esc(r['备注'])+'</td></tr>').join('')) + '</div></div>';
  }
  return h;
}
function viewMon(){ return tableOf(D.month, [['汇报人','汇报人'],['月份','月份'],['v2026','2026(万)'],
  ['v2025','2025(万)'],['来源','来源']], D.perPerson.map(r=>r['人']), r=>r['汇报人']); }

function viewErp(){
  const E = D.erp;
  if (!E) return '<div class="card"><h2>ERP 对账</h2><div class="muted">未找到 数据/2026Q3_ERP对账.json</div></div>';
  const K = E.kpi;
  const cards = [['ERP 两部门实际', n2(K['ERP两部门_万'])+' 万'],
                 ['报告自报合计', n2(K['报告汇总_万'])+' 万'],
                 ['未在报告中体现', n2(K['差额_万'])+' 万'],
                 ['报告覆盖率', K['报告覆盖率_%']+' %'],
                 ['大客户部(ERP)', n2(K['ERP大客户部_万'])+' 万'],
                 ['销售拓展部(ERP)', n2(K['ERP拓展部_万'])+' 万']];
  let h = '<div class="card"><h2>ERP 实际 × 报告自报 · 总额对账</h2><div class="kpis">' +
    cards.map(c=>'<div class="kpi"><div class="k">'+esc(c[0])+'</div><div class="v">'+c[1]+'</div></div>').join('') +
    '</div><div class="muted">' + esc(E.meta['ERP口径']) + '｜' + esc(E.meta['期间']) + '｜' + esc(E.meta['归并规则']) +
    '<br>' + esc(E.meta['报告源']) + ' —— ' + esc(E.meta['说明']) + '</div></div>';

  const rows = E.rows.filter(r=>r['ERP实绩_万']!=null || r['报告实绩_万']!=null);
  h += '<div class="card"><h2>逐人对照（报告 vs ERP）</h2><div class="tw"><table><thead><tr>' +
    ['销售员','部门','报告实绩(万)','ERP实绩(万)','未体现(万)','本人直算(万)','归并量(万)','ERP客户数','ERP单据数','说明']
      .map((c,i)=>'<th class="'+(i>1&&i<9?'num':'')+'">'+c+'</th>').join('') + '</tr></thead><tbody>' +
    rows.map(r => {
      const d = r['差额_万'];
      const note = r['报告实绩_万']==null ? '<b style="color:#c0392b">报告未提及（全额未体现）</b>'
        : (d!=null && d<0 ? '<span style="color:#c0392b">报告少于实际 '+n2(Math.abs(d))+' 万</span>'
        : (d!=null && d>0 ? '报告高于实际 '+n2(d)+' 万（含预估/跨期）' : '一致'));
      return '<tr><td>'+esc(r['人'])+'</td><td>'+esc(r['部门'])+'</td>' +
        '<td class="num">'+(r['报告实绩_万']==null?'—':n2(r['报告实绩_万']))+'</td>' +
        '<td class="num">'+n2(r['ERP实绩_万'])+'</td>' +
        '<td class="num">'+(d==null||d===0?'—':(d>0?'+':'')+n2(d))+'</td>' +
        '<td class="num">'+n2(r['本人直算_万'])+'</td>' +
        '<td class="num">'+(r['归并量_万']?n2(r['归并量_万']):'—')+'</td>' +
        '<td class="num">'+r['ERP客户数']+'</td><td class="num">'+r['ERP单据数']+'</td>' +
        '<td>'+note+'</td></tr>'; }).join('') + '</tbody></table></div></div>';

  const TWO = ['大客户部','销售拓展部'];
  const r2 = rows.filter(r=>TWO.indexOf(r['部门'])>=0);
  const rep2 = r2.filter(r=>r['报告实绩_万']!=null);
  const erp2 = r2.reduce((a,b)=>a+(b['ERP实绩_万']||0),0);
  const repT = rep2.reduce((a,b)=>a+b['报告实绩_万'],0);
  const miss2 = r2.filter(r=>r['报告实绩_万']==null && r['ERP实绩_万']>0);
  const under2 = rep2.filter(r=>r['差额_万']!=null && r['差额_万']<0);
  const over2 = rep2.filter(r=>r['差额_万']!=null && r['差额_万']>0);
  const gapU = under2.reduce((a,b)=>a+Math.abs(b['差额_万']),0);
  const gapO = over2.reduce((a,b)=>a+b['差额_万'],0);
  const gapM = miss2.reduce((a,b)=>a+b['ERP实绩_万'],0);
  h += '<div class="card"><h2>差额桥（可勾稽）</h2><div class="tw"><table><thead><tr>' +
    ['项','金额(万)','说明'].map(c=>'<th>'+c+'</th>').join('') + '</tr></thead><tbody>' +
    '<tr><td>ERP 两部门实际</td><td class="num"><b>'+n2(erp2)+'</b></td><td>系统过账（含离职归并）</td></tr>' +
    '<tr><td>＝ 报告自报合计</td><td class="num">'+n2(repT)+'</td><td>'+rep2.length+' 人提交并填了实绩</td></tr>' +
    '<tr><td>＋ 报告者少报部分</td><td class="num">'+n2(gapU-gapO)+'</td><td>'+under2.length+' 人低于实际（减 '+n2(gapO)+' 万高报）</td></tr>' +
    '<tr><td>＋ 未提交报告者业绩</td><td class="num">'+n2(gapM)+'</td><td>'+miss2.length+' 人：'+miss2.map(r=>esc(r['人'])).join('、')+'</td></tr>' +
    '<tr><td><b>合计缺口</b></td><td class="num"><b style="color:#c0392b">'+n2(erp2-repT)+'</b></td><td>占 ERP 实际 '+
    ((erp2-repT)/erp2*100).toFixed(1)+'%</td></tr></tbody></table></div></div>';
  const miss = miss2, under = under2;
  h += '<div class="card"><h2>① 报告未体现金额、但 ERP 有成交的人（'+miss.length+' 人 / '+
    n2(miss.reduce((a,b)=>a+b['ERP实绩_万'],0))+' 万）</h2><ul>' +
    miss.map(r=>'<li><b>'+esc(r['人'])+'</b>（'+esc(r['部门'])+'）：ERP '+n2(r['ERP实绩_万'])+' 万 ｜ '+r['ERP客户数']+' 客户 / '+r['ERP单据数']+' 单</li>').join('') +
    '</ul></div>';
  h += '<div class="card"><h2>② 报告有、但金额低于 ERP 的人（'+under.length+' 人 / 缺口 '+
    n2(under.reduce((a,b)=>a+Math.abs(b['差额_万']),0))+' 万）</h2><ul>' +
    under.map(r=>'<li><b>'+esc(r['人'])+'</b>：报告 '+n2(r['报告实绩_万'])+' 万 ｜ ERP '+n2(r['ERP实绩_万'])+
      ' 万 ｜ <span style="color:#c0392b">少 '+n2(Math.abs(r['差额_万']))+' 万</span></li>').join('') +
    '</ul></div>';
  const life = rows.filter(r=>r['部门']==='生命科学部');
  if (life.length) {
    const lifeSum = life.reduce((a,b)=>a+(b['报告实绩_万']||0),0);
    h += '<div class="card"><h2>③ 生命科学部（参考 · 不计入上面 '+n2(rows.filter(r=>r['部门']!=='生命科学部').reduce((a,b)=>a+(b['报告实绩_万']||0),0))+' 万口径）</h2><ul>' +
      life.map(r=>'<li><b>'+esc(r['人'])+'</b>：报告 '+n2(r['报告实绩_万'])+' 万 ｜ ERP '+(r['ERP实绩_万']==null?'—':n2(r['ERP实绩_万']))+' 万 ｜ '+r['ERP客户数']+' 客户 / '+r['ERP单据数']+' 单</li>').join('') +
      '</ul><div class="muted">本轮该部门 8 人已全部报送（报告合计 '+n2(lifeSum)+' 万）；逐人目标/课题组/四季度预测见「生命科学部」Tab。为保持与前两季度口径一致，本页合计不并入该部门。</div></div>';
  }
  return h;
}

function viewBuckets(){
  const B = ['≤30天','31–60天','61–90天','91–180天','>180天'];
  let h = '<div class="card"><h2>逾期账龄分档（按人 × 档位）</h2><div class="muted">档位按「逾期天数」划分；金额单位随原表（元）。</div>' +
    '<div class="tw"><table><thead><tr><th>销售员</th><th>部门</th>' +
    B.map(b=>'<th class="num">'+b+' 笔</th><th class="num">'+b+' 额</th>').join('') +
    '<th class="num">合计笔数</th><th class="num">合计金额</th></tr></thead><tbody>' +
    D.buckets.map(r => '<tr><td>'+esc(r['人'])+'</td><td>'+esc(r['部门'])+'</td>' +
      B.map(b=>'<td class="num">'+(r[b+'_笔']||0)+'</td><td class="num">'+n2(r[b+'_额'])+'</td>').join('') +
      '<td class="num"><b>'+r['合计笔数']+'</b></td><td class="num"><b>'+n2(r['合计金额'])+'</b></td></tr>').join('') +
    '</tbody></table></div></div>';
  h += '<div class="card"><h2>分档明细</h2>' + toolbar(D.buckets.map(r=>r['人'])) +
    '<div class="muted" style="margin-top:4px">提示：本页明细按档位着色。</div></div>';
  h += tableOf(D.bucketRows, [['销售员','销售员'],['往来单位','往来单位'],['单据编号','单据编号'],['到期日','到期日'],
    ['逾期天数','逾期天数'],['档位','账龄档'],['逾期金额','逾期金额'],['10月回款进度','10月回款进度']],
    D.buckets.map(r=>r['人']), r=>r['销售员']).replace('<div class="card">','<div class="card" style="margin-top:-8px">');
  return h;
}

function viewClusters(){
  let h = '<div class="card"><h2>四季度难点 / 前三季度异常 · 关键词聚类</h2>' +
    '<div class="muted">规则法（关键词词典）对 '+D.opp.length+' 条商机的「四季度冲刺难点及措施」与「异常原因复盘」分类，' +
    '一条可命中多类；样例为原文摘录，未做改写。</div></div>';
  h += D.clusters.map(c =>
    '<div class="card"><h2>'+esc(c['类别'])+' <span class="pill p-gray">'+c['条数']+' 条</span> ' +
    '<span class="pill p-gray">'+c['人数']+' 人</span></h2>' +
    '<div class="muted">涉及：'+esc(c['涉及人'])+'</div><ul>' +
    c['样例'].map(s => '<li><b>'+esc(s['人'])+'</b> · '+esc(s['客户'])+'：'+esc(s['原文'])+'</li>').join('') +
    '</ul></div>').join('');
  return h;
}

function viewLS(){
  const t = D.lsTarget || [], kt = D.lsKT || [], fc = D.lsFC || [];
  const pct = (v) => (v==='' || v==null || isNaN(Number(v))) ? '—' : (Number(v)*100).toFixed(1)+'%';
  let h = '<div class="card"><h2>① 月度目标与实绩（按人 × 月，单位随原表 · 多为万元）</h2>' +
    '<div class="muted">来源：生命科学销售部各人 PPT 内嵌「2026年销售目标」表，原表直读，未做换算。</div>' +
    '<div class="tw">' + mkTable(['txt','txt','num','num','num','num'],
      ['销售员','部门','月份','销售目标','实际完成','完成率'],
      t.map(r => '<tr><td>'+esc(r['人'])+'</td><td>'+esc(r['部门'])+'</td>' +
        '<td class="num">'+r['月份']+'</td><td class="num">'+n2(r['销售目标'])+'</td>' +
        '<td class="num">'+n2(r['实际完成'])+'</td><td class="num">'+pct(r['完成率'])+'</td></tr>').join('')) +
    '</div></div>';
  h += '<div class="card"><h2>② 重点课题组（课题组＝商机单位，共 '+kt.length+' 条）</h2>' +
    '<div class="muted">字段含：学校 / 课题组 / 商机状态 / 意向产品 / 决策人 / 课题组模式 / 年度目标 / 合作产品 / 痛点 / 研究方向 / 计划与难点（原文完整展示）。</div>' +
    '<div class="tw">' + mkTable(['txt','long','txt','txt','txt','txt','txt','num','long','long','long','long','long'],
      ['销售员','学校','课题组','状态','意向产品','决策人','模式','年度目标(w)','合作产品','痛点','研究方向','上半年计划','下半年保障难点'],
      kt.map(r => '<tr><td>'+esc(r['人'])+'</td><td class="long">'+esc(r['学校'])+'</td>' +
        '<td>'+esc(r['名称'])+'</td><td>'+esc(r['商机状态'])+'</td><td>'+esc(r['意向产品'])+'</td>' +
        '<td>'+esc(r['决策人'])+'</td><td>'+esc(r['课题组模式'])+'</td><td class="num">'+n2(r['年度目标_w'])+'</td>' +
        '<td class="long">'+esc(r['合作产品'])+'</td><td class="long">'+esc(r['痛点'])+'</td>' +
        '<td class="long">'+esc(r['研究方向'])+'</td>' +
        '<td class="long">'+esc(r['上半年计划'])+'</td><td class="long">'+esc(r['下半年难点'])+'</td></tr>').join('')) +
    '</div></div>';
  h += '<div class="card"><h2>③ 四季度预测（共 '+fc.length+' 条）</h2>' +
    '<div class="muted">来源：各人 PPT 内嵌「2026年销售预测（四季度/下半年）」表；金额单位随原表。</div>' +
    '<div class="tw">' + mkTable(['txt','long','txt','long','txt','txt','long','num','num','num','num','num','num'],
      ['销售员','客户','课题组','研究方向','人数','采购模式','重点任务','合计','上半年完成','3季度达成','4季度预测','4季度达成','全年完成'],
      fc.map(r => '<tr><td>'+esc(r['人'])+'</td><td class="long">'+esc(r['客户名称'])+'</td>' +
        '<td>'+esc(r['课题组'])+'</td><td class="long">'+esc(r['研究方向'])+'</td><td>'+esc(r['人数'])+'</td>' +
        '<td>'+esc(r['采购模式'])+'</td><td class="long">'+esc(r['重点任务'])+'</td><td class="num">'+n2(r['合计'])+'</td>' +
        '<td class="num">'+n2(r['上半年完成'])+'</td><td class="num">'+n2(r['q3达成'])+'</td>' +
        '<td class="num">'+n2(r['q4预测'])+'</td><td class="num">'+n2(r['q4达成'])+'</td>' +
        '<td class="num">'+n2(r['全年完成'])+'</td></tr>').join('')) +
    '</div></div>';
  const pm = D.prodMix || [], ms = D.marketScan || [], ma = D.marketAgg || [];
  if (pm.length) {
    h += '<div class="card"><h2>④ 产品线销售额构成（共 '+pm.length+' 条 · 原表直读）</h2>' +
      '<div class="muted">来源：'+esc(pm[0]['人'])+' PPT 内嵌「产品线销售额构成」表；销售额单位随原表（元），占比为原表给出值。</div>' +
      '<div class="tw">' + mkTable(['txt','long','num','num'], ['销售员','产品线','销售额','占比'],
        pm.map(r => '<tr><td>'+esc(r['人'])+'</td><td class="long">'+esc(r['产品线'])+'</td>' +
          '<td class="num">'+n2(r['销售额'])+'</td><td class="num">'+
          (r['占比']===''||r['占比']==null||isNaN(Number(r['占比'])) ? '—' : (Number(r['占比'])*100).toFixed(2)+'%')+
          '</td></tr>').join('')) + '</div></div>';
  }
  if (ma.length) {
    h += '<div class="card"><h2>⑤ 科研竞品在用格局（按产品线 × 在用品牌 · 来源：广东区市场扫描 '+ms.length+' 个课题组）</h2>' +
      '<div class="muted">口径：对每个课题组的每一产品线，若原表记录了我司/竞品品牌则计 1；「诺唯赞占比」= 该产品线中提到诺唯赞的课题组数 ÷ 该产品线有用量记录的课题组数。原表为驻点扫描记录，未做推断。</div>' +
      '<div class="tw">' + mkTable(['long','num','num','long'],
        ['产品线','在用课题组数','诺唯赞占比','品牌构成（前 8）'],
        ma.map(r => '<tr><td class="long">'+esc(r['产品线'])+'</td><td class="num">'+n2(r['在用课题组数'])+'</td>' +
          '<td class="num">'+r['诺唯赞占比'].toFixed(1)+'%</td><td class="long">'+esc(r['品牌构成'])+'</td></tr>').join('')) +
      '</div></div>';
  }
  if (ms.length) {
    h += '<div class="card"><h2>⑥ 课题组 · 竞品在用扫描全量（'+ms.length+' 条 · 表内滚动）</h2>' +
      '<div class="muted">来源：广东区「市场扫描」表（两阶段：分子扩增系列 / 细胞蛋白系列）；空白＝原表未记录该产品线用量。</div>' +
      '<div class="tw">' + mkTable(
        ['txt','txt','long','long','txt','txt','txt','txt','txt','txt','txt','txt','txt','txt','txt','txt'],
        ['单位','位置','课题组','高保真PCR','同源重组酶','小提/大提','RT逆转录','Q荧光定量','普通PCR','内切酶',
         'ECL发光液','蛋白marker','蛋白胶','蛋白定量','PVDF','销售员'],
        ms.map(r => '<tr><td>'+esc(r['单位'])+'</td><td>'+esc(r['位置'])+'</td><td class="long">'+esc(r['课题组'])+'</td>' +
          ['高保真PCR','同源重组酶','小提/大提','RT逆转录','Q荧光定量','普通PCR','内切酶','ECL发光液','蛋白marker','蛋白胶','蛋白定量','PVDF']
            .map(k => '<td>'+esc(r[k]||'')+'</td>').join('') +
          '<td>'+esc(r['人'])+'</td></tr>').join('')) + '</div></div>';
  }
  return h;
}

function viewGaps(){
  const g = D.gaps;
  let h = '<div class="card"><h2>口径与数据源</h2>' +
    '<div class="hint">本页数据 100% 取自 '+esc(D.meta['来源'])+'，未做任何换算；金额单位随原表（多为元）。</div>' +
    '<div class="muted">生成时间：'+esc(D.meta['生成'])+'<br>来源：'+esc(D.meta['来源'])+'<br>口径：'+esc(D.meta['口径'])+'</div>' +
    '<div class="kpis" style="margin-top:10px">' + Object.entries(D.counts).map(([k,v]) =>
      '<div class="kpi"><div class="k">'+k+'</div><div class="v">'+v+'<i>行</i></div></div>').join('') + '</div></div>';
  h += '<div class="card"><h2>缺口与待确认</h2><ul>' +
    Object.entries(g).map(([k,v]) => '<li><b>'+k+'</b>：'+(Array.isArray(v)? (v.length? v.map(x=>esc(typeof x==='string'?x:(x['文件']+' / '+x['工作表']))).join('；') : '无') : esc(v))+'</li>').join('') +
    '</ul></div>';
  h += '<div class="card"><h2>工作簿盘点（'+D.inv.length+' 张表）· 含真实数据行审计</h2><div class="tw"><table><thead><tr>' +
  ['文件','工作表','形状','判定','真实数据行','表头行'].map(c=>'<th>'+c+'</th>').join('') + '</tr></thead><tbody>' +
  D.inv.map(i => '<tr><td>'+esc(i['文件'])+'</td><td>'+esc(i['工作表']||'')+'</td><td>'+esc(i['形状']||'')+
    '</td><td>'+esc(i['判定']||'')+'</td><td class="num">'+esc(i['真实数据行']==null?'—':i['真实数据行'])+
    '</td><td>'+esc(i['表头行']==null?'':i['表头行'])+'</td></tr>').join('') +
  '</tbody></table></div></div>';
  return h;
}

function bind(){
  const g = document.getElementById('q');
  if (g){
    g.oninput = (e) => { q = e.target.value; const p = e.target.selectionStart; render(); const n = document.getElementById('q'); n.focus(); n.setSelectionRange(p,p); };
    const w = document.getElementById('who');
    if (w) w.onchange = e => { who = e.target.value; render(); };
    const c = document.getElementById('clr');
    if (c) c.onclick = () => { q = who = ''; sortKey = null; render(); };
    const x = document.getElementById('csv');
    if (x) x.onclick = exportCSV;
  }
  document.querySelectorAll('th[data-k]').forEach(th => th.onclick = () => {
    const k = th.dataset.k;
    if (sortKey === k) sortDir = -sortDir; else { sortKey = k; sortDir = -1; }
    render();
  });
}

function exportCSV(){
  const {rows:d, cols} = window.__cur || {rows:[], cols:[]};
  const lines = [cols.map(c=>c[1]).join(',')].concat(d.map(r => cols.map(c =>
    '"'+String(r[c[0]]==null?'':r[c[0]]).replace(/"/g,'""')+'"').join(',')));
  const b = new Blob(['\ufeff'+lines.join('\n')], {type:'text/csv;charset=utf-8'});
  const a = document.createElement('a');
  a.href = URL.createObjectURL(b); a.download = 'Q3数据台_'+tab+'.csv';
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
}

document.getElementById('go').onclick = async () => {
  const v = document.getElementById('pw').value;
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(v));
  const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
  if (hex === GATE){ document.getElementById('gate').remove(); render(); }
  else document.getElementById('err').textContent = '口令不正确';
};
document.getElementById('pw').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('go').click(); });
</script>
</body></html>
'''


if __name__ == '__main__':
    main()
