#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
宝锐 2026 基础数据核对表（全部部门 · 36 行 = 35 名销售员 + 新增 1 人）— 本地自包含 HTML 生成器
==========================================================================
【用途】用户要求「把基础数据以列表形式呈现出来，让我能看，能修正」。
       本页为**一次性内部核对表**：含全员姓名 + 金额 + 目标 → 内部敏感数据。
【红线】只写本地文件。不部署工作台 Tab、不推 CloudBase、不上传任何服务器。
【数据源（只读）】
  ① 数据/三部门逐月_2026.json      → depts / total（部门级 · 新口径 600 / 6650）
  ② 该文件的「合并前」备份（自动选取，见 _resolve_premerge）
                                    → salespersons（个人级 · 35 人，保留离职者各自金额）
  ③ 数据/diag_mappings.json        → sp_to_dept / sp_to_region
  ④ 数据/三部门逐月_2026.json meta.ls_region.region_map → 生命科学部 广东/浙江 归属
【2026-10-06 用户拍板口径】
  · 生命科学部 900 → 600 万（广东 300 / 浙江 300）
  · 缺口摊区域经理：吴云 190（含 110）/ 韩远怀 165（含 65，刘斌离职释放 30 后重摊）
  · 新增人员 张晋菲 15 万（生命科学部 · 浙江，无 2026 订单）
  · 离职/管培 11 人业绩归区域经理（行与金额保留，仅加标注）
  · 全司年度目标 6950 → 6650 万
【为什么个人行取「合并前」快照】用户要求离职者「一行一人、口径不变、能看到全貌」；
  但当前 JSON 已把离职者并入经理（mgr_merge），35 行已被折叠为 26 行。
  故个人行取合并前快照，部门行取当前 JSON —— 两者金额逐分母总额守恒。
【品牌】脚本/brand_kit.py 注入 Logo + slogan（页眉/页脚），零外链。
用法：python3 脚本/gen_basedata_review.py
"""
import io
import json
import os
import sys
import glob
import datetime
from collections import defaultdict

W = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(W, '脚本'))
import brand_kit as BK  # noqa: E402

SRC_MONTHLY = os.path.join(W, '数据', '三部门逐月_2026.json')
SRC_MAPPING = os.path.join(W, '数据', 'diag_mappings.json')
OUT = os.path.join(W, '2026基础数据核对表_全部部门_可修正.html')

MONTHS_9 = ['2026-%02d' % i for i in range(1, 10)]
DEPTS = ['大客户部', '销售拓展部', '生命科学部', '不计入']
RESIGNED = ['黄建佳', '黄自鑫', '黄明月', '龙友波', '赵以诺', '段玉', '梁莹莹', '曾琪', '李岚风',
            '王慧洁', '刘静文', '刘斌']

# ── 2026-10-06 拍板口径（新值 / 原值 双轨，改这里全表生效）────────────────
BASELINE_DEPT_TARGET = {'大客户部': 2650, '销售拓展部': 3400, '生命科学部': 600}
BASELINE_DEPT_TARGET_OLD = {'大客户部': 2650, '销售拓展部': 3400, '生命科学部': 900}
TOTAL_BUDGET = 6650
TOTAL_BUDGET_OLD = 6950
LS_ANNUAL_BUDGET = 600.0
LS_ANNUAL_BUDGET_OLD = 900.0
LS_TGT_COVERAGE = 600.0            # 生命科学部 8 名有年度目标者之和（广东 3 + 浙江 5，含新增张晋菲）
LS_TGT_COVERED_N = 8
LS_TGT_PEOPLE_N = 19               # 18（看板明细）+ 新增张晋菲
LS_REGION_BUDGET = {'广东': 300.0, '浙江': 300.0}
LS_REGION_MANAGER = {'广东': '韩远怀', '浙江': '吴云'}
LS_PERSON_TARGETS = {
    '广东': {'韩远怀': 165.0, '黄建华': 100.0, '王诗香': 35.0},
    '浙江': {'吴云': 190.0, '袁殿果': 35.0, '叶信福': 30.0, '侯海瑜': 30.0, '张晋菲': 15.0},
}
LS_PERSON_TARGET_OLD = {'韩远怀': 100.0, '吴云': 80.0}     # 缺口摊派前
LS_PERSON_SHORTFALL = {'韩远怀': 65.0, '吴云': 110.0}
MGR_MERGE = {'赵以诺': '吴云', '李岚风': '吴云', '曾琪': '吴云',
             '王慧洁': '吴云', '刘静文': '吴云',
             '黄建佳': '韩远怀', '黄自鑫': '韩远怀', '段玉': '韩远怀',
             '梁莹莹': '韩远怀', '肖立': '韩远怀', '刘斌': '韩远怀',
             '龙友波': '黄文才'}
NEW_HIRES = ['张晋菲']
NEW_HIRE_TARGET = 15.0


# ── 读源 ──────────────────────────────────────────────────────────────
def _resolve_premerge(cur_path, n_people=35, ls_annual=900):
    """个人级（合并前）快照：无 meta.mgr_merge、35 名销售员、生命科学部年度目标 900。
    取 (mtime, 文件名) 最大者 —— 可复现，不写死备份文件名。"""
    best = None
    for f in glob.glob(cur_path + '*'):
        if os.path.abspath(f) == os.path.abspath(cur_path):
            continue
        try:
            d = json.load(io.open(f, encoding='utf-8'))
        except Exception:
            continue
        if not isinstance(d, dict) or 'meta' not in d or 'salespersons' not in d:
            continue
        if d['meta'].get('mgr_merge'):
            continue
        if len(set(r['sp'] for r in d['salespersons'])) != n_people:
            continue
        if (d.get('depts', {}).get('生命科学部', {}) or {}).get('target_annual') != ls_annual:
            continue
        k = (os.path.getmtime(f), os.path.basename(f))
        if best is None or k > best[0]:
            best = (k, f)
    return best[1] if best else None


D = json.load(io.open(SRC_MONTHLY, encoding='utf-8'))
MAP = json.load(io.open(SRC_MAPPING, encoding='utf-8'))
SP2D = MAP.get('sp_to_dept', {})
SP2R = MAP.get('sp_to_region', {})

SRC_PREMERGE = _resolve_premerge(SRC_MONTHLY)
assert SRC_PREMERGE, '未找到个人级（合并前）快照：需「35 人 + 生命科学部年度目标 900」的三部门逐月_2026.json 备份'
DPM = json.load(io.open(SRC_PREMERGE, encoding='utf-8'))
rows_raw = DPM['salespersons']

# 生命科学部 广东 / 浙江 归属（当前 JSON meta.ls_region.region_map 为权威源）
LS_REGION_MAP = (D['meta'].get('ls_region') or {}).get('region_map') or {}

# ── 口径变更（person_merge，用户 2026-10-06 指定）──────────────────────
PM = D['meta'].get('person_merge') or {}
GEN_SRC_TS = D['meta'].get('generated', '')

# 合并前口径证据：从最近的、无 person_merge 的备份逐人实测
PRE = None
for _f in glob.glob(os.path.join(W, '数据', '三部门逐月_2026.json*')):
    if os.path.abspath(_f) == os.path.abspath(SRC_MONTHLY):
        continue
    try:
        _d = json.load(io.open(_f, encoding='utf-8'))
    except Exception:
        continue
    if not isinstance(_d, dict) or 'meta' not in _d or 'salespersons' not in _d:
        continue
    if _d['meta'].get('person_merge'):
        continue
    _n = len(set(r['sp'] for r in _d['salespersons']))
    if _n < 36:
        continue
    if PRE is not None and os.path.getmtime(_f) <= PRE['mt']:
        continue
    _amt, _tgt = {}, {}
    for r in _d['salespersons']:
        _amt[r['sp']] = _amt.get(r['sp'], 0) + (r['amt'] or 0)
        if r.get('target_annual') is not None:
            _tgt[r['sp']] = r['target_annual']
    PRE = {'mt': os.path.getmtime(_f), 'file': os.path.basename(_f), 'n': _n,
           'lzy': round(_amt.get('刘子研', 0), 2), 'lxy': round(_amt.get('刘新元', 0), 2),
           'lzy_tgt': _tgt.get('刘子研')}

# ── 按人聚合（个人级 · 合并前 35 人）──────────────────────────────────
agg = defaultdict(lambda: {'dept': None, 'amt': 0.0, 'custs': 0, 'target': 0.0,
                           'target_annual': None, 'months': 0, 'ach_ytd': None})
for r in rows_raw:
    a = agg[r['sp']]
    a['dept'] = r['dept']
    a['amt'] += (r['amt'] or 0)
    a['custs'] += (r['custs'] or 0)
    a['target'] += (r['target'] or 0)
    a['months'] += 1
    if r.get('target_annual') is not None:
        a['target_annual'] = r['target_annual']
    if r.get('achieve_ytd') is not None:
        a['ach_ytd'] = r['achieve_ytd']

PEOPLE = sorted(agg.items(), key=lambda kv: -kv[1]['amt'])


def biz_region(name, dept):
    """生命科学部 → 广东 / 浙江；其余部门 None。"""
    if dept != '生命科学部':
        return None
    return LS_REGION_MAP.get(name)


def target_new(name, dept, ta):
    """2026-10-06 拍板后的个人年度目标（仅生命科学部被改）。

    生命科学部：优先取 LS_PERSON_TARGETS（新口径）；已离职并入区域经理者
    （MGR_MERGE，如 刘斌 30 万）个人年度目标不再单列（其缺口已重摊给区域经理）→ None。
    """
    if dept == '生命科学部':
        for _rg, dd in LS_PERSON_TARGETS.items():
            if name in dd:
                return dd[name]
        if name in MGR_MERGE:
            return None
    return ta


rows = []
for name, v in PEOPLE:
    dept = v['dept']
    ta = v['target_annual']
    tsum = round(v['target'], 2)
    notes = []
    if name in MGR_MERGE:
        notes.append('离职·业绩已归 %s（口径见页顶「本次口径变更」）' % MGR_MERGE[name])
    elif name in RESIGNED:
        notes.append('离职名单成员（仍计入统计）')
    if name == '刘斌':
        notes.append('2026-10-06 离职 → 原广东个人 30 万目标释放并重摊给韩远怀（广东缺口 35→65）；'
                     '本人明细行保留、不再单列年度目标')
    if name not in SP2D:
        notes.append('sp_to_dept 未映射')
    _tnew = target_new(name, dept, ta)
    if _tnew is None:
        notes.append('无年度目标')
    if dept == '生命科学部' and _tnew is not None:
        notes.append('生命科学部年度目标 %d 万（部门 600＝广东300+浙江300，8 人合计 600 万）' % _tnew)
    if name == '吴云':
        notes.append('浙江区域经理；缺口摊派 %.0f → %.0f 万（含浙江缺口 %.0f 万）'
                     % (LS_PERSON_TARGET_OLD[name], LS_PERSON_TARGETS['浙江'][name],
                        LS_PERSON_SHORTFALL[name]))
    if name == '韩远怀':
        notes.append('广东区域经理；缺口摊派 %.0f → %.0f 万（含广东缺口 %.0f 万）'
                     % (LS_PERSON_TARGET_OLD[name], LS_PERSON_TARGETS['广东'][name],
                        LS_PERSON_SHORTFALL[name]))
    if name == '刘子研':
        notes.append('年度目标 1300 万＝用户 2026-10-06 指定的大客户1岗目标（非数据错误，勿当错改）')
        if PRE:
            notes.append('644.11 万＝本人 %.2f + person_merge 并入的刘新元 %.2f（黄明月/张立娅 1-9 月无单独金额）'
                         % (PRE['lzy'], PRE['lxy']))
        notes.append('无 sp_to_dept 归属')
    if name == '赵云浩':
        notes.append('个人年度目标 1350 万（大客户2岗）')
    if v['months'] < 9:
        notes.append('1-9 月仅 %d 个月有非零金额' % v['months'])
    if dept == '生命科学部' and name == '黄建华':
        notes.append('生命科学部 1-9 月最高个人（6.80 万）')
    st = '在职'
    if name in MGR_MERGE:
        st = '离职·业绩已归' + MGR_MERGE[name]
    elif name in RESIGNED:
        st = '离职'
    rows.append({
        'name': name,
        'dash_dept': dept,
        'amt': round(v['amt'], 2),
        'target_annual': target_new(name, dept, ta),
        'target_annual_src': ta,
        'target_sum': tsum,
        'mapping': SP2D.get(name, '未映射'),
        'region': SP2R.get(name, '—'),
        'biz_region': biz_region(name, dept) or '—',
        'status': st,
        'merged_into': MGR_MERGE.get(name),
        'months': v['months'],
        'ach_ytd': v['ach_ytd'],
        'note': '；'.join(notes) if notes else '',
    })

# ── 新增人员：张晋菲（生命科学部 · 浙江；2026 KPI 15 万；ERP 无 2026 订单）──
ZHANG_AMT = 0.0
ZHANG_NOTE = ('浙江区域第 5 人；2026 KPI 15 万（用户 2026-10-06 指定）；订单情况待核'
              '——已检索 ERP《销售订单_20260101-20260930.xlsx》（39 名销售员，含「张/菲/晋/飞」等'
              '拼写变体）未检索到 2026 订单 → 本表实际按 0 计')
rows.append({
    'name': '张晋菲',
    'dash_dept': '生命科学部',
    'amt': ZHANG_AMT,
    'target_annual': NEW_HIRE_TARGET,
    'target_annual_src': NEW_HIRE_TARGET,
    'target_sum': 0.0,
    'mapping': SP2D.get('张晋菲', '未映射'),
    'region': '浙江科研',
    'biz_region': '浙江',
    'status': '新增人员',
    'merged_into': None,
    'months': 0,
    'ach_ytd': None,
    'note': ZHANG_NOTE,
})

assert len(rows) == 36, '明细行数应为 36（35 + 张晋菲），实际 %d' % len(rows)

# ── 部门汇总（部门级取当前 JSON = 新口径 600，非手写）────────────────
dept_rows = []
for k, v in D['depts'].items():
    t9 = sum(v['target_monthly'][m] for m in MONTHS_9)
    a9 = sum(v['actual_monthly'][m] for m in MONTHS_9)
    a25 = sum(v['actual_2025'][m] for m in MONTHS_9)
    n = len(set(r['sp'] for r in rows_raw if r['dept'] == k))
    yoy = (a9 / a25 - 1) * 100 if a25 else None
    note = '2025 同期 %.2f 万，同比 %+.1f%%' % (a25, yoy)
    if k == '生命科学部':
        note += '；口径修正 900→600 万（另 +1 名新增人员张晋菲，无 2026 订单，不在看板明细）'
    dept_rows.append({'dept': k, 'ta': v['target_annual'],
                      'ta_old': BASELINE_DEPT_TARGET_OLD.get(k, v['target_annual']),
                      't9': t9, 'a9': a9, 'rate': a9 / t9 * 100, 'n': n, 'note': note})
T9 = sum(x['t9'] for x in dept_rows)
A9 = sum(x['a9'] for x in dept_rows)
N = sum(x['n'] for x in dept_rows)
assert N == 35, N
LS_A9 = [x for x in dept_rows if x['dept'] == '生命科学部'][0]['a9']
LS_T9 = [x for x in dept_rows if x['dept'] == '生命科学部'][0]['t9']

# ── 生命科学部 广东 / 浙江 区域行 ─────────────────────────────────────
region_rows = []
for _rg, _mg in LS_REGION_MANAGER.items():
    rv = D['depts']['生命科学部']['regions'][_rg]
    r_t9 = round(sum(rv['target_monthly'][m] for m in MONTHS_9), 2)
    r_a9 = round(sum(rv['actual_monthly'][m] for m in MONTHS_9), 2)
    region_rows.append({
        'label': '生命科学部 · ' + _rg,
        'ta': rv['target_annual'], 't9': r_t9, 'a9': r_a9,
        'rate': r_a9 / r_t9 * 100, 'n': len(LS_PERSON_TARGETS[_rg]),
        'note': '区域经理 %s（年度目标 %.0f 万，含缺口 %.0f 万）；%d 人：%s，小计 %.0f 万'
                % (_mg, rv['manager_target_annual'], rv['manager_shortfall'],
                   len(LS_PERSON_TARGETS[_rg]),
                   ' / '.join('%s %g' % (n, t) for n, t in LS_PERSON_TARGETS[_rg].items()),
                   sum(LS_PERSON_TARGETS[_rg].values())),
    })

# 离职名单成员在明细中的金额
res_amt = {r['name']: r['amt'] for r in rows if r['name'] in RESIGNED and r['amt'] > 0}
res_sum = round(sum(res_amt.values()), 2)
RES7 = ['龙友波', '黄建佳', '李岚风', '黄自鑫', '赵以诺', '梁莹莹', '段玉']
res7_sum = round(sum(res_amt.get(k, 0) for k in RES7), 2)

unmapped = [r['name'] for r in rows if r['mapping'] == '未映射']
unmapped_ls = [r['name'] for r in rows if r['mapping'] == '未映射' and r['dash_dept'] == '生命科学部']
map_only = sorted(set(SP2D) - set(r['name'] for r in rows))
ls_no_target = [r['name'] for r in rows if r['dash_dept'] == '生命科学部' and r['target_annual'] is None]
ls_targeted = [r['name'] for r in rows if r['dash_dept'] == '生命科学部' and r['target_annual'] is not None]
assert len(ls_targeted) == LS_TGT_COVERED_N, ls_targeted
assert round(sum(r['target_annual'] for r in rows
                 if r['dash_dept'] == '生命科学部' and r['target_annual'] is not None), 2) \
    == LS_ANNUAL_BUDGET, '生命科学部 8 人目标之和应为 600'
for _rg, _dd in LS_PERSON_TARGETS.items():
    assert abs(sum(_dd.values()) - 300.0) < 1e-9, (_rg, sum(_dd.values()))
assert abs(sum(sum(d.values()) for d in LS_PERSON_TARGETS.values()) - LS_ANNUAL_BUDGET) < 1e-9
assert TOTAL_BUDGET == 2650 + 3400 + 600

# ── 生成时间 ─────────────────────────────────────────────────────────
GEN_TS = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')

CHANGE_LOG = [
    '生命科学部年度目标 <b>900 → 600 万</b>（原 900 万口径作废；12 个月度目标＝原形态 ×2/3，'
    '部门 12 月合计精确 <b>600.0</b>）。',
    '生命科学部拆两个区域：<b>广东 300 万 / 浙江 300 万</b>（表 A 已新增两个区域行）。',
    '缺口按区域摊给区域经理：<b>吴云 80 → 190 万</b>（浙江，含缺口 110）、'
    '<b>韩远怀 100 → 165 万</b>（广东，含缺口 65）；其余人员目标不动。',
    '新增人员 <b>张晋菲</b>（生命科学部 · 浙江，浙江区域第 5 人），2026 年度 KPI <b>15 万</b>'
    '（用户 2026-10-06 指定）；ERP 未检索到 2026 订单 → 本表实际按 0 计、状态标「新增人员」。',
    '<b>【本轮追加】刘斌 / 王慧洁 / 刘静文 离职，业绩归区域经理；广东缺口重摊 → 韩远怀 165</b> —— '
    '刘斌（广州科研，原广东个人目标 30 万）→ <b>韩远怀</b>：其 30 万目标随离职释放，'
    '广东缺口由 35 万增为 <b>65 万</b>摊给韩远怀（<b>135 → 165 万</b>），广东区域仍 <b>300 万</b>；'
    '王慧洁 / 刘静文（浙江科研，本无个人年度目标）→ <b>吴云</b>。三人均已离职，'
    '明细行与历史金额<b>保留</b>、仅加状态标注。',
    '离职 / 管培 <b>11 人</b>业绩已归区域经理（<b>行与个人金额保留，仅加标注</b>，不得删除）：'
    '赵以诺 / 李岚风 / 曾琪 / 王慧洁 / 刘静文 → <b>吴云</b>；'
    '黄建佳 / 黄自鑫 / 段玉 / 梁莹莹 / 肖立 / 刘斌 → <b>韩远怀</b>；'
    '龙友波 → <b>黄文才</b>。王昕伟：去年离职、无 2026 订单（本就不在明细行内，仅此说明）。',
    '全司年度目标合计 <b>6,950 → 6,650 万</b>（大客户 2650 + 销售拓展 3400 + 生命科学 600）。',
]

DATA = {
    'rows': rows,
    'dept_options': DEPTS,
    'dept_annual_target': BASELINE_DEPT_TARGET,
    'dept_annual_target_old': BASELINE_DEPT_TARGET_OLD,
    'ls_annual_budget': LS_ANNUAL_BUDGET,
    'ls_annual_budget_old': LS_ANNUAL_BUDGET_OLD,
    'total_budget': TOTAL_BUDGET,
    'total_budget_old': TOTAL_BUDGET_OLD,
    'ls_region_budget': LS_REGION_BUDGET,
    'ls_person_targets': LS_PERSON_TARGETS,
    'ls_person_target_old': LS_PERSON_TARGET_OLD,
    'mgr_merge': MGR_MERGE,
    'new_hires': NEW_HIRES,
    'resigned': RESIGNED,
    'ls_a9': round(LS_A9, 2),
    'ls_tgt_coverage': LS_TGT_COVERAGE,
    'ls_tgt_covered_n': LS_TGT_COVERED_N,
    'ls_tgt_people_n': LS_TGT_PEOPLE_N,
    'gen': GEN_TS,
    'ls_key': 'biori_basedata_review_v1',
    'source': '数据/三部门逐月_2026.json（meta.generated=%s，mgr_merge 已生效） + 数据/diag_mappings.json（ERP 2026-01-01~2026-09-30）' % GEN_SRC_TS,
    'source_person': '个人行 35 人取合并前快照 数据/%s（meta.generated=%s，无 mgr_merge）'
                     % (os.path.basename(SRC_PREMERGE), DPM['meta'].get('generated', '')),
    'change_log': CHANGE_LOG,
    'person_merge': PM,
    'pre_merge': PRE,
}

CSS = """
:root{--blue:#1852B6;--dark:#0d2a52;--line:#e3e8f0;--muted:#6b7785;--bg:#f4f6fa;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:#1b2430;font-size:14px;
 font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:1560px;margin:0 auto;padding:18px 22px 40px}
h1{font-size:22px;margin:14px 0 6px;color:var(--dark);letter-spacing:.5px}
.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px;line-height:1.7}
.conclusion{border-left:5px solid #d93025;background:#fff;border-radius:10px;padding:14px 18px;
 box-shadow:0 1px 4px rgba(13,42,82,.07);margin-bottom:18px}
.conclusion .big{font-size:15.5px;font-weight:700;color:#b3261e;line-height:1.6}
.conclusion .small{font-size:12.5px;color:#4a5568;line-height:1.8;margin-top:7px}
section{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(13,42,82,.07);margin-bottom:18px;overflow:hidden}
.sec-h{padding:12px 18px;background:linear-gradient(135deg,#0d2a52,#1852B6);color:#fff;
 display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600}
.sec-h .tagm{font-size:11px;font-weight:500;background:rgba(255,255,255,.16);padding:2px 9px;border-radius:10px;letter-spacing:.5px}
.sec-b{padding:16px 18px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{border:1px solid var(--line);padding:7px 9px;vertical-align:middle}
thead th{background:#eef2f9;color:#26374a;font-weight:600;white-space:nowrap;position:relative}
tbody tr:nth-child(even){background:#fafbfd}
tbody tr:hover{background:#eef4ff}
td.n{text-align:right;font-variant-numeric:tabular-nums;font-family:ui-monospace,Menlo,monospace}
th.n{text-align:right}
td.c,th.c{text-align:center}
tr.tot td{background:#eaf0fa;font-weight:700}
tr.sub td{background:#f6f9ff}
tr.sub td:first-child{padding-left:28px}
tr.sub td:first-child:before{content:"└ ";color:#9bb0d0}
.old{color:#98a2b3;text-decoration:line-through;font-weight:400}
table.sortable thead th[data-key]{cursor:pointer;user-select:none;padding-right:20px}
table.sortable thead th[data-key]:after{content:"\\\\2195";position:absolute;right:6px;opacity:.35;font-size:11px}
table.sortable thead th[data-key].asc:after{content:"\\\\2191";opacity:.95;color:var(--blue)}
table.sortable thead th[data-key].desc:after{content:"\\\\2193";opacity:.95;color:var(--blue)}
.bad{color:#b3261e;font-weight:700}
.warnc{color:#b06d00;font-weight:700}
.okc{color:#0a7c3f;font-weight:700}
.toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:12px 18px;background:#f7f9fd;border-bottom:1px solid var(--line)}
#search{flex:1 1 280px;min-width:220px;padding:8px 12px;border:1px solid #c8d2e0;border-radius:8px;font-size:13.5px}
#search:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px rgba(24,82,182,.12)}
.cnt{font-size:12.5px;color:var(--muted)}
.cnt b{color:var(--blue)}
.btn{border:1px solid #c8d2e0;background:#fff;color:#25384f;border-radius:8px;padding:8px 14px;
 font-size:13px;cursor:pointer;font-family:inherit}
.btn:hover{border-color:var(--blue);color:var(--blue)}
.btn.primary{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600}
.btn.primary:hover{background:#12409a}
.btn.danger{color:#b3261e;border-color:#e8b7b3}
.btn.danger:hover{background:#fdf1f0}
select.ed-dept{padding:5px 6px;border:1px solid #c8d2e0;border-radius:6px;font-size:12.5px;background:#fff;font-family:inherit;min-width:118px}
input.ed-tgt{width:92px;padding:5px 7px;border:1px solid #c8d2e0;border-radius:6px;font-size:12.5px;
 text-align:right;font-family:ui-monospace,Menlo,monospace}
select.ed-dept:focus,input.ed-tgt:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px rgba(24,82,182,.14)}
tbody tr.mod{background:#fff8e1 !important;box-shadow:inset 4px 0 0 #f0a800}
tbody tr.mod:hover{background:#fff3cf !important}
tbody tr.off{opacity:.5;text-decoration:line-through;text-decoration-color:#b3261e}
tbody tr.off td.n{text-decoration:line-through}
.pill{display:inline-block;font-size:11px;padding:1px 7px;border-radius:9px;white-space:nowrap}
.pill.leave{background:#fdecea;color:#b3261e;border:1px solid #f3c2bd}
.pill.on{background:#e8f5ec;color:#0a7c3f;border:1px solid #bfe3cb}
.pill.newh{background:#eaf0ff;color:#12409a;border:1px solid #bcd0f7}
.pill.nomap{background:#fff5e0;color:#b06d00;border:1px solid #f0d9a8}
.sumtab{max-width:1000px}
.sumtab caption{caption-side:top;text-align:left;font-size:12.5px;color:var(--muted);padding-bottom:6px}
#sum{margin-top:14px;border-top:1px dashed var(--line);padding-top:14px}
#sum h3{font-size:13.5px;margin:0 0 8px;color:var(--dark)}
.out{margin:0 18px 16px;border:1px solid #c8d2e0;border-radius:8px;background:#0d1b2e;overflow:hidden}
.out-h{background:#183a68;color:#dbe7fa;font-size:12px;padding:7px 12px;display:flex;justify-content:space-between;align-items:center}
.out pre{margin:0;padding:12px;color:#c8f7d4;font-size:12px;max-height:280px;overflow:auto;white-space:pre-wrap;word-break:break-all}
.out button{background:transparent;border:0;color:#dbe7fa;font-size:15px;cursor:pointer}
.susp{border-left:5px solid #f0a800;background:#fffdf5;border-radius:8px;padding:11px 15px;margin-bottom:11px;border:1px solid #f2e2bb;border-left-width:5px}
.mergebox{border-left:5px solid #1852B6;background:#f2f7ff;border:1px solid #cddffb;border-radius:8px;padding:12px 16px;margin-bottom:14px}
.mergebox .t{font-size:14px;font-weight:700;color:#12409a;line-height:1.6}
.mergebox .e{font-size:12.5px;color:#3b4757;line-height:1.85;margin-top:6px}
.mergebox .e b{color:#12203a}
.mergebox code{background:#e3ecfb;padding:1px 5px;border-radius:4px;font-size:12px}
.chgbox{border-left:5px solid #0a7c3f;background:#f3fbf6;border:1px solid #c6e6d3;border-radius:10px;
 padding:14px 18px;margin-bottom:18px;box-shadow:0 1px 4px rgba(13,42,82,.07)}
.chgbox .t{font-size:15.5px;font-weight:700;color:#0a6b38;line-height:1.6}
.chgbox .t .when{font-size:11.5px;font-weight:600;background:#dff3e7;color:#0a6b38;
 padding:2px 8px;border-radius:9px;margin-left:8px;vertical-align:2px}
.chgbox ol{margin:9px 0 0 20px;padding:0;font-size:12.9px;line-height:1.95;color:#3b4757}
.chgbox li{margin-bottom:2px}
.chgbox li b{color:#0d2a52}
.chgbox .e{font-size:12.2px;color:#5b6876;line-height:1.8;margin-top:9px}
.susp .t{font-size:14px;font-weight:700;color:#8a5a00;line-height:1.55}
.susp .e{font-size:12.5px;color:#4a5568;line-height:1.8;margin-top:5px}
.susp .e b{color:#1b2430}
.hint{font-size:12px;color:var(--muted);line-height:1.8;margin:10px 0 0}
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:10px}
.sw{display:inline-flex;align-items:center;gap:6px;cursor:pointer}
.legend i{display:inline-block;width:13px;height:13px;border-radius:3px;vertical-align:-2px;margin-right:4px}
@media(max-width:760px){.wrap{padding:10px}h1{font-size:18px}td,th{font-size:12px;padding:5px 6px}}
"""

JS = r"""
(function(){
'use strict';
var DATA = JSON.parse(document.getElementById('DATA').textContent);
var ROWS = DATA.rows, DEPTS = DATA.dept_options, LS_KEY = DATA.ls_key;
var BASE_TGT = DATA.dept_annual_target;
var byName = {}; ROWS.forEach(function(r){ byName[r.name]=r; });
var edits = loadEdits();
var sortKey='amt', sortDir=-1, q='', composing=false;

function $(s){ return document.querySelector(s); }
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){
  return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
function has(o,k){ return Object.prototype.hasOwnProperty.call(o,k); }
function numOrNull(v){ if(v===null||v===undefined||v==='') return null; var n=Number(v); return isNaN(n)?null:n; }

function loadEdits(){
  try{ var s=localStorage.getItem(LS_KEY);
    if(s){ var o=JSON.parse(s)||{}; o.dept=o.dept||{}; o.target_annual=o.target_annual||{}; o.exclude=o.exclude||[];
           if(Object.prototype.toString.call(o.exclude)!=='[object Array]') o.exclude=[]; return o; }
  }catch(e){}
  return {dept:{},target_annual:{},exclude:[]};
}
function saveEdits(){ try{ localStorage.setItem(LS_KEY, JSON.stringify(edits)); }catch(e){} }

function curDept(r){ return has(edits.dept,r.name) ? edits.dept[r.name] : r.dash_dept; }
function curTargetNum(r){ return has(edits.target_annual,r.name) ? numOrNull(edits.target_annual[r.name]) : numOrNull(r.target_annual); }
function baseTargetNum(r){ return numOrNull(r.target_annual); }
function isIncluded(r){ return edits.exclude.indexOf(r.name) < 0; }
function isModified(r){
  if(has(edits.dept,r.name) && edits.dept[r.name]!==r.dash_dept) return true;
  if(curTargetNum(r)!==baseTargetNum(r)) return true;
  if(!isIncluded(r)) return true;
  return false;
}
function statusPill(r){
  var st = String(r.status==null?'':r.status);
  var cls = st.indexOf('离职')===0 ? 'leave' : (st==='新增人员' ? 'newh' : 'on');
  var h='<span class="pill '+cls+'">'+esc(st)+'</span>';
  if(r.mapping==='未映射') h+=' <span class="pill nomap">未映射</span>';
  return h;
}
function rowHTML(r,idx){
  var cd=curDept(r), ct=curTargetNum(r), inc=isIncluded(r);
  var cls=(isModified(r)?'mod ':'')+(inc?'':'off');
  var opts=DEPTS.map(function(d){ return '<option value="'+esc(d)+'"'+(d===cd?' selected':'')+'>'+esc(d)+'</option>'; }).join('');
  var tv=(ct===null?'':ct);
  var nm=' data-name="'+esc(r.name)+'"';
  return '<tr'+nm+' class="'+cls+'">'
   + '<td class="c">'+(idx+1)+'</td>'
   + '<td><b>'+esc(r.name)+'</b></td>'
   + '<td>'+esc(r.dash_dept)+'</td>'
   + '<td class="c">'+esc(r.biz_region||'—')+'</td>'
   + '<td class="n">'+r.amt.toFixed(2)+'</td>'
   + '<td class="n">'+(r.target_annual==null?'<span class="warnc">—</span>':Number(r.target_annual).toFixed(2))+'</td>'
   + '<td class="n">'+(r.target_sum>0?r.target_sum.toFixed(2):'—')+'</td>'
   + '<td>'+(r.mapping==='未映射'?'<span class="warnc">未映射</span>':esc(r.mapping))+'</td>'
   + '<td>'+esc(r.region)+'</td>'
   + '<td class="c">'+statusPill(r)+'</td>'
   + '<td><select class="ed-dept"'+nm+'>'+opts+'</select></td>'
   + '<td class="n"><input class="ed-tgt" type="number" step="0.01" min="0"'+nm+' value="'+tv+'" placeholder="'+(r.target_annual==null?'待补':'')+'"></td>'
   + '<td class="c"><label class="sw"><input type="checkbox" class="ed-inc"'+nm+(inc?' checked':'')+'></label></td>'
   + '<td style="font-size:12px;color:#5b6876;line-height:1.65">'+esc(r.note)+'</td>'
   + '</tr>';
}
function cmpv(a,b){
  if(typeof a==='number'||typeof b==='number'){
    var x=(a==null||a==='')?-Infinity:Number(a), y=(b==null||b==='')?-Infinity:Number(b);
    return x-y;
  }
  return String(a==null?'':a).localeCompare(String(b==null?'':b),'zh-Hans-CN');
}
function sortVal(r){
  if(sortKey==='cur_dept') return curDept(r);
  if(sortKey==='cur_target') return curTargetNum(r);
  if(sortKey==='inc') return isIncluded(r)?1:0;
  return r[sortKey];
}
function viewRows(){
  var rows=ROWS.slice();
  if(q){
    var k=q.toLowerCase();
    rows=rows.filter(function(r){
      return [r.name,r.dash_dept,r.mapping,r.region,r.biz_region,r.status,r.note,curDept(r)].join(' ').toLowerCase().indexOf(k)>=0;
    });
  }
  rows.sort(function(a,b){ return sortDir*cmpv(sortVal(a),sortVal(b)); });
  return rows;
}
function markRow(name){
  var r=byName[name]; if(!r) return;
  var tr=document.querySelector('#tbody tr[data-name="'+name+'"]'); if(!tr) return;
  tr.classList.toggle('mod', isModified(r));
  tr.classList.toggle('off', !isIncluded(r));
}
function render(){
  var rows=viewRows();
  $('#tbody').innerHTML = rows.map(function(r,i){ return rowHTML(r,i); }).join('');
  updateStats(rows);
}
function updateStats(rows){
  rows = rows || viewRows();
  $('#cntRows').textContent = rows.length + ' / ' + ROWS.length;
  var mod = ROWS.filter(isModified).length;
  $('#cntMod').textContent = mod;
  var g={};
  ROWS.forEach(function(r){
    if(!isIncluded(r)) return;
    var d=curDept(r); if(d==='不计入') return;
    if(!g[d]) g[d]={n:0,amt:0,tgt:0,tgtN:0};
    g[d].n++; g[d].amt+=r.amt;
    var t=curTargetNum(r);
    if(t!=null){ g[d].tgt+=t; g[d].tgtN++; }
  });
  var order=['大客户部','销售拓展部','生命科学部'];
  Object.keys(g).forEach(function(d){ if(order.indexOf(d)<0) order.push(d); });
  var tn=0,ta=0,tt=0,rowsH='';
  order.forEach(function(d){
    if(!g[d]) return;
    var x=g[d]; tn+=x.n; ta+=x.amt; tt+=x.tgt;
    var base=BASE_TGT[d];
    var dy=(base!=null)? x.tgt-base : null;
    rowsH += '<tr><td><b>'+esc(d)+'</b></td><td class="c">'+x.n+'</td>'
      + '<td class="n">'+x.amt.toFixed(2)+'</td><td class="n">'+x.tgt.toFixed(2)+'</td>'
      + '<td class="n">'+(base!=null?base.toFixed(2):'—')+'</td>'
      + '<td class="n">'+(dy==null?'—':(dy>=0?'+':'')+dy.toFixed(2))+'</td>'
      + '<td class="c">'+x.tgtN+' / '+x.n+'</td></tr>';
  });
  rowsH += '<tr class="tot"><td>合计</td><td class="c">'+tn+'</td><td class="n">'+ta.toFixed(2)+'</td>'
    + '<td class="n">'+tt.toFixed(2)+'</td><td class="n">'+DATA.total_budget.toFixed(2)+'</td>'
    + '<td class="n">'+(tt-DATA.total_budget>=0?'+':'')+(tt-DATA.total_budget).toFixed(2)+'</td><td class="c">—</td></tr>';
  $('#sumBox').innerHTML = '<h3>修正后按部门合计（实时重算 · 口径基准＝2026-10-06 拍板：生科 600 / 全司 6650）</h3>'
    + '<table class="sumtab"><caption>口径：仅统计「是否计入」勾选 且 修正后部门≠不计入 的行；人数＝计入人数；「覆盖」＝有年度目标的人数/计入人数；'
    + '实际/目标为个人数值四舍五入后求和，与部门级（A 表）可能有 ±0.05 万级差异。</caption>'
    + '<thead><tr><th>修正后部门</th><th class="c">计入人数</th><th class="n">1-9月实际(万)</th>'
    + '<th class="n">修正后年度目标(万)</th><th class="n">原部门目标(万)</th><th class="n">目标差(万)</th>'
    + '<th class="c">覆盖</th></tr></thead><tbody>' + rowsH + '</tbody></table>';
}
function buildJSON(){
  var o={dept:{},target_annual:{},exclude:[]};
  ROWS.forEach(function(r){
    if(has(edits.dept,r.name) && edits.dept[r.name]!==r.dash_dept) o.dept[r.name]=edits.dept[r.name];
    if(curTargetNum(r)!==baseTargetNum(r)) o.target_annual[r.name]=curTargetNum(r);
    if(!isIncluded(r)) o.exclude.push(r.name);
  });
  o.exported=new Date().toISOString();
  return o;
}
function buildCSV(){
  var head=['序号','姓名','看板部门','业务区域','1-9月实际(万)','年度目标(万)','月目标合计(万)','sp_to_dept归属','region','在职/离职','修正后部门','修正后年度目标(万)','是否计入','备注','是否已修改'];
  var out=[head], rows=viewRows();
  rows.forEach(function(r,i){
    out.push([i+1,r.name,r.dash_dept,(r.biz_region||''),r.amt.toFixed(2),
      (r.target_annual==null?'':Number(r.target_annual).toFixed(2)),
      (r.target_sum>0?r.target_sum.toFixed(2):''),r.mapping,r.region,r.status,
      curDept(r), (curTargetNum(r)==null?'':curTargetNum(r)), (isIncluded(r)?'是':'否'),
      r.note, (isModified(r)?'是':'')]);
  });
  var s='\ufeff'+out.map(function(row){
    return row.map(function(c){ var v=String(c==null?'':c);
      return /[",\n\r]/.test(v) ? '"'+v.replace(/"/g,'""')+'"' : v; }).join(',');
  }).join('\r\n');
  return s;
}
function ts(){ var d=new Date(),p=function(n){return (n<10?'0':'')+n;};
  return d.getFullYear()+p(d.getMonth()+1)+p(d.getDate())+'-'+p(d.getHours())+p(d.getMinutes())+p(d.getSeconds()); }
function showOut(label,txt){ $('#out').hidden=false; $('#outPre').textContent=label+'\n'+txt; }
function download(name,text,mime){
  try{
    var blob=new Blob([text],{type:mime+';charset=utf-8'});
    var url=URL.createObjectURL(blob);
    var a=document.createElement('a'); a.href=url; a.download=name;
    document.body.appendChild(a); a.click();
    setTimeout(function(){ URL.revokeObjectURL(url); if(a.parentNode) a.parentNode.removeChild(a); },1500);
    return true;
  }catch(e){ return false; }
}

/* —— 编辑（事件委托：改完只更新本行 + 汇总，不整表重渲染 → 不打断输入法/焦点）—— */
$('#tbody').addEventListener('input', function(e){
  var t=e.target; if(!t.classList||!t.classList.contains('ed-tgt')) return;
  var n=t.getAttribute('data-name'); if(!n) return;
  var v=t.value.trim();
  edits.target_annual[n] = (v===''?'':Number(v));
  if(v!=='' && isNaN(Number(v))) edits.target_annual[n]='';
  saveEdits(); markRow(n); updateStats();
});
$('#tbody').addEventListener('change', function(e){
  var t=e.target, n=t.getAttribute('data-name'); if(!n) return;
  if(t.classList.contains('ed-dept')){ edits.dept[n]=t.value; }
  else if(t.classList.contains('ed-inc')){
    var i=edits.exclude.indexOf(n);
    if(t.checked){ if(i>=0) edits.exclude.splice(i,1); }
    else if(i<0){ edits.exclude.push(n); }
  } else { return; }
  saveEdits(); markRow(n); updateStats();
});

/* —— 搜索：中文输入法保护（composition 期间不重渲染）—— */
var sEl=$('#search');
sEl.addEventListener('compositionstart', function(){ composing=true; });
sEl.addEventListener('compositionend', function(){ composing=false; q=sEl.value; render(); });
sEl.addEventListener('input', function(){ if(composing) return; q=sEl.value; render(); });

/* —— 排序 —— */
document.querySelectorAll('table.sortable thead th[data-key]').forEach(function(th){
  th.addEventListener('click', function(){
    var k=th.getAttribute('data-key');
    if(sortKey===k) sortDir=-sortDir; else { sortKey=k; sortDir=(k==='amt'||k==='target_annual'||k==='target_sum'||k==='cur_target')?-1:1; }
    document.querySelectorAll('table.sortable thead th[data-key]').forEach(function(x){
      x.classList.remove('asc','desc');
      if(x.getAttribute('data-key')===sortKey) x.classList.add(sortDir>0?'asc':'desc');
    });
    render();
  });
});

/* —— 按钮 —— */
$('#btnJson').addEventListener('click', function(){
  var o=buildJSON(), txt=JSON.stringify(o,null,2);
  var fn='基础数据修正_'+ts()+'.json';
  var ok=download(fn,txt,'application/json');
  showOut((ok?'已下载：':'下载被拦截，请手动复制：')+fn, txt);
});
$('#btnCsv').addEventListener('click', function(){
  var txt=buildCSV();
  var fn='基础数据核对表_'+ts()+'.csv';
  var ok=download(fn,txt,'text/csv');
  showOut((ok?'已下载：':'下载被拦截，请手动复制：')+fn, txt);
});
$('#btnReset').addEventListener('click', function(){
  if(!window.confirm('确定清空全部修改（部门 / 年度目标 / 是否计入）？此操作不可撤销。')) return;
  edits={dept:{},target_annual:{},exclude:[]};
  try{ localStorage.removeItem(LS_KEY); }catch(e){}
  $('#out').hidden=true; render();
});
$('#outClose').addEventListener('click', function(){ $('#out').hidden=true; });

/* —— 首屏 —— */
document.querySelector('table.sortable thead th[data-key="amt"]').classList.add('desc');
render();
window.__BASEDATA__ = { ROWS:ROWS, getEdits:function(){return edits;}, buildJSON:buildJSON, buildCSV:buildCSV, render:render };
})();
"""


def dept_table_html():
    out = ['<table><thead><tr>'
           '<th>部门 / 区域</th><th class="n">年度目标(万)</th><th class="n">原年度目标(万)</th>'
           '<th class="n">1-9 月目标(万)</th>'
           '<th class="n">1-9 月实际(万)</th><th class="n">达成率</th><th class="c">看板人数</th>'
           '<th>备注（同比 2025 同期）</th></tr></thead><tbody>']
    for x in dept_rows:
        cls = ' class="bad"' if x['rate'] < 60 else (' class="okc"' if x['rate'] >= 95 else ' class="warnc"')
        old = ('<span class="old">%s</span>' % '{:,.0f}'.format(x['ta_old'])) \
            if x['ta_old'] != x['ta'] else '{:,.0f}'.format(x['ta_old'])
        out.append('<tr><td><b>%s</b></td><td class="n">%s</td><td class="n">%s</td>'
                   '<td class="n">%.2f</td><td class="n">%.2f</td>'
                   '<td class="n"%s>%.2f%%</td><td class="c">%d</td>'
                   '<td style="font-size:12px;color:#5b6876">%s</td></tr>'
                   % (x['dept'], '{:,.0f}'.format(x['ta']), old, x['t9'], x['a9'], cls, x['rate'], x['n'], x['note']))
    for x in region_rows:
        cls = ' class="bad"' if x['rate'] < 60 else (' class="okc"' if x['rate'] >= 95 else ' class="warnc"')
        out.append('<tr class="sub"><td><b>%s</b></td><td class="n">%s</td><td class="n">—</td>'
                   '<td class="n">%.2f</td><td class="n">%.2f</td>'
                   '<td class="n"%s>%.2f%%</td><td class="c">%d</td>'
                   '<td style="font-size:12px;color:#5b6876">%s</td></tr>'
                   % (x['label'], '{:,.0f}'.format(x['ta']), x['t9'], x['a9'], cls, x['rate'], x['n'], x['note']))
    out.append('<tr class="tot"><td>合计</td><td class="n">%s</td><td class="n">%s</td>'
               '<td class="n">%.2f</td><td class="n">%.2f</td>'
               '<td class="n">%.2f%%</td><td class="c">%d</td>'
               '<td style="font-size:12px">合计达成率＝%.2f ÷ %.2f（加权），非三部门达成率均值。'
               '看板人数 35 人＝看板明细出现且金额&gt;0 者；另新增 1 人（张晋菲，无 2026 订单）不在看板明细。</td></tr>'
               % ('{:,.0f}'.format(TOTAL_BUDGET), '{:,.0f}'.format(TOTAL_BUDGET_OLD),
                  T9, A9, A9 / T9 * 100, N, A9, T9))
    out.append('</tbody></table>')
    return '\n'.join(out)


def change_log_html():
    items = '\n'.join('<li>%s</li>' % s for s in CHANGE_LOG)
    return ('<div class="chgbox"><div class="t">本次口径变更（2026-10-06）'
            '<span class="when">用户拍板 · 本页已按新口径重算</span></div>'
            '<ol>%s</ol>'
            '<div class="e">对照读法：表 A 的「原年度目标」列＝改前值（划线）；表 B 每人的「年度目标(万)」'
            '已是新口径，个人金额与行序为<b>合并前个人口径</b>（离职者各自一行，保留全貌）。'
            '若浏览器里还留着上一版的修改（localStorage 键 <code>%s</code>），本页会继续沿用 —— '
            '想清空请点表 B 的「重置全部修改」。</div></div>'
            % (items, DATA['ls_key']))


def merge_note_html():
    """今日 16:02 生效的口径变更（person_merge / target_annual_override）—— 核对前必读。"""
    if not PM:
        return '<div class="mergebox"><div class="t">0. 口径变更：源 JSON 未声明 person_merge</div>' \
               '<div class="e">未检出 person_merge，本表按原始逐人明细核对。</div></div>'
    mm = PM.get('merge_map', {})
    merged = '、'.join('%s → %s' % (k, v) for k, v in mm.items())
    ov = PM.get('target_annual_override', {})
    pre = ''
    if PRE:
        pre = ('<br><b>合并前口径实测</b>（备份 <code>%s</code>，%d 人）：刘子研 1-9 月 <b>%.2f</b> 万、年度目标 <b>%s</b>；'
               '合并后 1-9 月 <b>644.11</b> 万、年度目标 <b>%s</b>（金额 +%.2f 万，目标为用户指定覆盖）。'
               % (PRE['file'], PRE['n'], PRE['lzy'],
                  '—' if PRE['lzy_tgt'] is None else '%.2f 万' % PRE['lzy_tgt'],
                  ('%.2f 万' % ov['刘子研']) if '刘子研' in ov else '—',
                  644.11 - PRE['lzy']))
    return ('<div class="mergebox"><div class="t">0. 先读这条 · 今日 16:02 已生效的口径变更（是用户指定，'
            '不是数据错误）</div><div class="e">'
            '<b>person_merge</b>：%s 的销售额全部并入「刘子研」（大客户1岗）。来源：<code>%s</code>。'
            '影响面：salespersons[].sp（同月同部门目标/实际/客户数相加）、customers[].sales、'
            'customers_grp[].sales_list（后端 partial / other_owner_count 亦重算）。'
            '<br><b>target_annual_override</b>：刘子研年度目标 = <b>%s 万</b>（用户指定的大客户1岗全年目标）。%s'
            '<br><b>对核对表的含义</b>：看板「大客户部」的 1 行 = 在册 4 人的合并口径；'
            '<b>请勿把刘子研的 1300 万 / 644.11 万当成错误改掉</b> —— 要改请改「修正后部门 / 修正后年度目标」，'
            '并在备注里写明合并口径是否延续。'
            '</div></div>'
            % (merged or '（无映射明细）', PM.get('source', '用户指定'), ov.get('刘子研', '—'), pre))


def suspicion_html():
    res_ls = sum(res_amt.get(k, 0) for k in RESIGNED if byName_dept(k) == '生命科学部')
    items = [
        ('1. 生命科学部：8 人背 600 万年度目标，1-9 月实际仅 %.2f 万（%.2f%%）—— 目标已按 2026-10-06 拍板下修（原 900 万作废），仍离地'
         % (LS_A9, LS_A9 / LS_ANNUAL_BUDGET * 100),
         '源数据：depts.生命科学部 target_annual=<b>600</b>（<span class="old">原 900</span>），'
         'target_monthly 1-9 月合计=<b>%.2f</b>，actual_monthly 1-9 月合计=<b>%.2f</b>。'
         '同部门 2025 年同期实际 <b>21.24</b> 万（同比 <b>+87.7%%</b>）—— 实际在涨，是分母（目标）离地。'
         '8 名有目标者合计 <b>600</b> 万：广东 韩远怀165/黄建华100/王诗香35＝<b>300</b>；'
         '浙江 吴云190/袁殿果35/叶信福30/侯海瑜30/张晋菲15＝<b>300</b>。'
         '区域看：广东 1-9 月实际 <b>13.96</b> 万（目标 160.85）、浙江 <b>%.2f</b> 万（目标 160.85）。'
         % (LS_T9, LS_A9, [x for x in region_rows if x['label'].endswith('浙江')][0]['a9'])),
        ('2. 生命科学部 18 人（+新增 1 人）在 <code>sp_to_dept</code> 里<b>一个都没有（0/19）</b> —— 存在第二套部门归一机制，两套映射不一致',
         '<code>diag_mappings.json → sp_to_dept</code> 共 <b>24</b> 人；看板明细共 <b>35</b> 人。'
         '24 人里有 <b>%s</b> 这 8 人不在看板明细中 —— 其中 <b>张立娅、黄明月</b> 已按 person_merge 并入刘子研（见本节第 0 条），'
         '<b>上海</b> 疑为非人名占位，其余 5 人 1-9 月无金额或非销售岗，故不出现在 salespersons。'
         '而 18 名生命科学部人员全部不在 sp_to_dept 中 —— 看板部门取自 <code>三部门逐月_2026.json</code> 的 dept 字段（大客户→生命科学→销售拓展 判定顺序），'
         '与 sp_to_dept 是两套口径。<b>已核对：16 名同时出现在两处的人，部门判定全部一致，无冲突。</b>' % '、'.join(map_only),
         ),
        ('3. <b>19 人不在 <code>sp_to_dept</code> 里</b>（含刘子研 + 全部 18 名生命科学部人员 + 新增张晋菲）→ 明细表标记「未映射」',
         '未映射名单（19 人）：<b>%s</b>。<br>其中刘子研为看板第 1 名（644.11 万，占全司 18.7%%），'
         '却无 sp_to_dept 归属 —— 影响所有依赖 sp_to_dept 的按部门/区域统计。' % '、'.join(unmapped)),
        ('4. 大客户部看板只有 2 行，却背 2650 万年度目标（刘子研 1300 + 赵云浩 1350）',
         '<b>⚠ 本条按 16:02 后的口径已修正读法：</b>看板明细中大客户部 = 刘子研、赵云浩 <b>2 行</b>，'
         '1-9 月实际 <b>1106.82</b> 万，达成率 <b>54.05%</b>。'
         '但按 <code>meta.person_merge</code>（用户 2026-10-06 指定），大客户1岗已把 <b>黄明月 / 张立娅 / 刘新元</b> 并入 <b>刘子研</b> —— '
         '该岗在册 <b>4 人</b>，看板只显示 1 行。<br>'
         '所以 2650 万 = 大客户1岗（刘子研行，4 人口径，用户指定 1300 万）+ 大客户2岗（赵云浩 1350 万）—— '
         '<b>不是「2 个人背 2650 万」，而是「2 个目标责任人」，其中 1 个责任人的数字含 4 人合并</b>。'
         '另：sp_to_dept 里归「大客户部」的还有 <b>王昕伟</b> —— 他既不在看板明细、也不在合并名单：'
         '<b>去年离职、无 2026 订单</b>（仅记录说明，不做任何金额动作）。'),
        ('5. <b>离职人员仍在统计</b>：明细中 <b>%d 名离职者</b>合计 %.2f 万（本轮新增 刘斌 / 王慧洁 / 刘静文 三名已并入；'
         '另有 龙友波、黄建佳、李岚风、黄自鑫、赵以诺、梁莹莹、段玉、曾琪）'
         % (len(res_amt), res_sum),
         '逐行实测合计 <b>%.2f</b> 万（离职名单共 12 人；<b>黄明月</b> 的销售额已整体并入刘子研（person_merge），'
         '不在明细单列，故其 1-9 月金额无法单独统计）。'
         '<br><b>2026-10-06 拍板</b>：其中 11 人（赵以诺/李岚风/曾琪/王慧洁/刘静文→吴云；'
         '黄建佳/黄自鑫/段玉/梁莹莹/肖立/刘斌→韩远怀；龙友波→黄文才）的销售额在数据源里已并入区域经理，'
         '本表仍按<b>个人口径一行一人</b>列出并加注状态，便于对全貌；金额不重复计入（全司合计守恒）。'
         '<br>影响：全司 1-9 月 <b>%.2f</b> 万中含离职人员 %.2f 万（占 %.2f%%）；'
         '生命科学部 <b>%.2f</b> 万中含离职人员 %.2f 万（占 %.1f%%）。'
         % (res_sum, A9, res_sum, res_sum / A9 * 100,
            LS_A9, res_ls, res_ls / LS_A9 * 100)),
        ('6. 生命科学部 600 万目标覆盖 <b>8/19</b> 人（其余 11 人无年度目标）',
         '有年度目标的 8 人：韩远怀 165、黄建华 100、王诗香 35（广东＝300）；'
         '吴云 190、袁殿果 35、叶信福 30、侯海瑜 30、张晋菲 15（浙江＝300）→ 合计 <b>600</b> 万，'
         '与部门 600 万目标<b>完全闭合</b>（原口径 900 万下 8 人只占 440 万，缺口 460 万无处安放）。'
         '无年度目标的 %d 人：<b>%s</b>（含已离职者 刘斌 的历史行 —— 其 30 万已重摊给韩远怀）。'
         % (len(ls_no_target), '、'.join(ls_no_target))),
        ('7. 附带发现：1-6 月无逐人月目标',
         'meta.salesperson_targets：<code>rows_with_monthly_target=59 / %d</code>，'
         '逐人月目标仅 7-12 月有源（h2_months），1-6 月为 null。'
         '故明细表「月目标合计」列仅代表 7-9 月（看板可见月份）的个人月目标之和，<b>不是 1-9 月的完整个人目标</b>。'
         '22 人有目标源，14 人无目标源（任鑫、刘欣、刘静文、曾琪、李岚风、杜建、梁莹莹、段玉、王慧洁、肖立、赵以诺、黄建佳、黄自鑫、龙友波）——'
         '其中 9 人均为离职名单成员。' % (len(rows_raw),)),
    ]
    out = []
    for t, e in items:
        out.append('<div class="susp"><div class="t">%s</div><div class="e">%s</div></div>' % (t, e))
    return '\n'.join(out)


def byName_dept(name):
    for r in rows:
        if r['name'] == name:
            return r['dash_dept']
    return None


conclusion = (
    '<div class="big">结论：<b>生命科学部达成率低这件事，要先看口径</b> —— 按 2026-10-06 拍板，'
    '部门年度目标已由 <span class="old">900</span> 万下修到 <b>600</b> 万（广东 300 / 浙江 300），'
    '9 名有目标者合计正好 600 万；1-9 月实际 %.2f 万，同比 2025 <b>+87.7%%</b>，'
    '不是业绩崩塌信号，仍是「目标离地 + 人员小口径」。</div>'
    '<div class="small">'
    '四个理由：① 生命科学部 18 人在 <code>sp_to_dept</code> 里 0 人映射，看板部门来自另一套归一逻辑，'
    '全司 36 行里有 19 行无法被 sp_to_dept 解释；'
    '② 600 万目标已全部闭合到 8 人（广东 300 / 浙江 300），缺口按区域摊给区域经理（吴云 190、韩远怀 165）；'
    '③ 18 人 1-9 月合计 %.2f 万中，还含 11 名离职人员的历史金额（本表按个人口径保留，不删行）；'
    '④ 全司年度目标由 6,950 改为 <b>6,650</b> 万，本表 A 表已并列「原年度目标」列供对照。'
    '本表把这几层都摊开成可编辑行：请直接改「修正后部门 / 修正后年度目标 / 是否计入」，'
    '导出 JSON 后再决定要不要修数据源。'
    '</div>' % (LS_A9, LS_A9)
)

html = u"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>宝锐 2026 基础数据核对表 · 全部部门（可修正）</title>
<style>
__CSS__
</style>
</head>
<body>
<div class="wrap">
<h1>宝锐 2026 基础数据核对表 · 全部部门 / 全部销售员（36 行）</h1>
<div class="sub">
范围：大客户部 · 销售拓展部 · 生命科学部（3 个部门，<b>36 行</b>＝35 名销售员 + 新增 1 人 张晋菲）　｜　
数据源（只读）：<code>数据/三部门逐月_2026.json</code>（部门级，meta.generated=__GEN_SRC__，mgr_merge 已生效）+ 
合并前快照 <code>__SRC_PERSON__</code>（个人级，35 人）+ <code>数据/diag_mappings.json</code>　｜　
口径：ERP 标准销售订单 · 单据状态=已审核 · 非赠品 · 剔自体系客户 · 金额=价税合计（含税，万元）<br>
生成时间 __GEN_TS__　｜　
<b>本页为本地内部核对文件：未部署到工作台 Tab、未上传任何服务器、未推送到任何云平台。</b>
</div>

<div class="conclusion">
__CONCLUSION__
</div>

__CHANGELOG__

<section>
  <div class="sec-h">A · 部门汇总 <span class="tagm">数字直取源 JSON，非手抄</span></div>
  <div class="sec-b">
    __DEPT_TABLE__
    <div class="hint">达成率＝1-9 月实际 ÷ 1-9 月目标。「看板人数」＝该部门在 salespersons 明细中出现且金额 &gt;0 的人数。
      三部门目标（2026-10-06 拍板）：大客户 2650 + 拓展 3400 + 生命科学 <b>600</b> = <b>6650 万</b>
      （<span class="old">原 900 / 6950</span>）。生命科学部 12 个月度目标＝原形态 ×2/3，12 月合计精确 600.0；
      广东 / 浙江两行区域月目标＝部门当月 ÷ 2，各区 12 月合计 300.0。</div>
  </div>
</section>

<section>
  <div class="sec-h">B · 销售员明细（36 行，可编辑） <span class="tagm">改完即存 localStorage</span></div>
  <div class="toolbar">
    <input id="search" type="text" placeholder="搜索姓名 / 部门 / 区域 / 备注…（中文输入法已做保护）" autocomplete="off">
    <span class="cnt">显示 <b id="cntRows">…</b> 行 · 已修改 <b id="cntMod">0</b> 行</span>
    <span style="flex:1 1 auto"></span>
    <button class="btn primary" id="btnJson">导出修正 JSON</button>
    <button class="btn" id="btnCsv">导出 CSV（含 BOM）</button>
    <button class="btn danger" id="btnReset">重置全部修改</button>
  </div>
  <div class="sec-b" style="padding-bottom:0">
    <div class="legend">
      <span><i style="background:#fff8e1;border:1px solid #f0a800"></i>黄底＝你有修改的行</span>
      <span><i style="background:#fff;border:1px solid #e3e8f0"></i>灰化划线＝「是否计入」已取消</span>
      <span>点击任意列头可排序（默认按 1-9 月实际降序）</span>
      <span>「不计入」部门 ≠ 取消勾选，但两者都不计入下方汇总</span>
    </div>
  </div>
  <div style="overflow:auto;padding:14px 18px 18px">
    <table class="sortable">
      <thead>
        <tr>
          <th class="c" data-key="idx" style="width:46px">序号</th>
          <th data-key="name" style="min-width:74px">姓名</th>
          <th data-key="dash_dept" style="min-width:96px">看板部门</th>
          <th class="c" data-key="biz_region" style="min-width:64px">业务区域</th>
          <th class="n" data-key="amt" style="min-width:96px">1-9月实际(万)</th>
          <th class="n" data-key="target_annual" style="min-width:92px">年度目标(万)</th>
          <th class="n" data-key="target_sum" style="min-width:96px">月目标合计(万)</th>
          <th data-key="mapping" style="min-width:88px">sp_to_dept 归属</th>
          <th data-key="region" style="min-width:80px">region</th>
          <th class="c" data-key="status" style="min-width:126px">在职/离职</th>
          <th data-key="cur_dept" style="min-width:132px">【可编辑】修正后部门</th>
          <th class="n" data-key="cur_target" style="min-width:118px">【可编辑】修正后年度目标</th>
          <th class="c" data-key="inc" style="width:78px">【可编辑】是否计入</th>
          <th style="min-width:230px">备注</th>
        </tr>
      </thead>
      <tbody id="tbody"></tbody>
    </table>
  </div>
  <div class="sec-b" id="sum"><div id="sumBox"></div></div>
  <div class="out" id="out" hidden>
    <div class="out-h"><span id="outTitle">导出结果</span><button id="outClose" title="关闭">×</button></div>
    <pre id="outPre"></pre>
  </div>
</section>

<section>
  <div class="sec-h">C · 可疑点清单（已查实，带证据） <span class="tagm">按严重度排序</span></div>
  <div class="sec-b">
    __MERGENOTE__
    __SUSPICION__
    <div class="hint">第 0 条为今日已生效的口径变更（必读，影响第 2/4/5 条的读法）；第 1-6 条为已核实项；第 7 条为核对过程中附带发现的证据（月目标口径），一并留档。</div>
  </div>
</section>

<section>
  <div class="sec-h">D · 怎么用这张表</div>
  <div class="sec-b">
    <div class="hint">
      1. 逐行看「看板部门」是否该归到别的部门 → 用【可编辑】修正后部门下拉改；<br>
      2. 逐行看「年度目标」是否合理（生命科学部 8 人有目标、大客户部 2 人）→ 直接在【可编辑】修正后年度目标输入；<br>
      3. 离职 / 不该计入统计的人 → 取消【可编辑】是否计入的勾选（本表按用户要求<b>不删除</b>离职行，只标注）；<br>
      4. 改完点「导出修正 JSON」（文件名带时间戳，格式：<code>{{\"dept\":{{姓名:部门}},\"target_annual\":{{姓名:值}},\"exclude\":[姓名,…],\"exported\":\"ISO 时间\"}}</code>），
      把 JSON 发回来，我再改数据源；<br>
      5. 所有修改即时存浏览器 localStorage（同一浏览器刷新不丢），并点「导出 CSV」可给同事复核；<br>
      6. <b>注意今日已生效的两批口径变更</b>：① 大客户1岗（黄明月 / 张立娅 / 刘新元 → 刘子研）；② 生命科学部 600 万 /
      广东 300 / 浙江 300、缺口摊区域经理、新增张晋菲、离职 11 人归经理。若你认为不该这么做，请在备注写清，导出 JSON 后一并说明；本表不会替你改数据源。<br>
      <b>注意</b>：本页只是「修正意见表」，<b>不会自动改任何数据源</b>；数据源仍以导出 JSON 为准人工/脚本回填。
    </div>
  </div>
</section>
</div>

<script id="DATA" type="application/json">__DATA_JSON__</script>
<script>
__JS__
</script>
</body>
</html>
"""

html = (html
        .replace('__CSS__', CSS)
        .replace('__JS__', JS)
        .replace('__GEN_TS__', GEN_TS)
        .replace('__GEN_SRC__', GEN_SRC_TS)
        .replace('__SRC_PERSON__', os.path.basename(SRC_PREMERGE))
        .replace('__CONCLUSION__', conclusion)
        .replace('__CHANGELOG__', change_log_html())
        .replace('__DEPT_TABLE__', dept_table_html())
        .replace('__SUSPICION__', suspicion_html())
        .replace('__MERGENOTE__', merge_note_html())
        .replace('__DATA_JSON__', json.dumps(DATA, ensure_ascii=False)))

html = BK.inject(html, theme='dark', tag='内部核对 · 仅本地查看')
assert '<script id="DATA"' in html

io.open(OUT, 'w', encoding='utf-8').write(html)
v = BK.check_file(OUT)
print('OUT:', OUT)
print('bytes:', os.path.getsize(OUT))
print('brand verify:', v)
print('rows:', len(rows), 'T9=%.2f A9=%.2f N=%d' % (T9, A9, N))
print('LS: t9=%.2f a9=%.2f | 广东 a9=%.2f 浙江 a9=%.2f'
      % (LS_T9, LS_A9, [x for x in region_rows if x['label'].endswith('广东')][0]['a9'],
         [x for x in region_rows if x['label'].endswith('浙江')][0]['a9']))
print('LS 目标: 广东=%.1f 浙江=%.1f 合计=%.1f / 部门=%s'
      % (sum(LS_PERSON_TARGETS['广东'].values()), sum(LS_PERSON_TARGETS['浙江'].values()),
         sum(sum(d.values()) for d in LS_PERSON_TARGETS.values()),
         D['depts']['生命科学部']['target_annual']))
print('premerge snapshot:', os.path.basename(SRC_PREMERGE))
print('res7_sum=%.2f res_sum=%.2f' % (res7_sum, res_sum))
