#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成【目标基础数据管理台】页面 工具/仪表盘/目标基础数据管理台.html。

· 自包含、零外链（无 <link>/<script src>；Logo 为 data URI，由 brand_kit 注入）。
· **静态 HTML 本体零姓名、零金额**：数据运行时经鉴权接口 /api/targets 拉取（服务端按角色过滤，仅 admin/gm）。
· brand_kit 页眉页脚；中文输入法保护（编辑不重排整表，仅刷新汇总/闭合与已改人的月度单元格）。
· 三张表：① 部门/区域汇总 ② 人员表 ③ 变更日志；导出 JSON / CSV；localStorage 暂存；重置。

用法：/usr/bin/python3 脚本/gen_target_manage.py
"""
import io
import os
import sys

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

VERSION = '20261006h'
OUT = os.path.join(W, '工具', '仪表盘', '目标基础数据管理台.html')

CSS = r"""
:root{--pri:#1B4F9C;--pri2:#3E8EDE;--dark:#0d2a52;--line:#e3ebf6;--muted:#6b7785;--bg:#eef3fa;
  --ok:#0a7c3f;--bad:#c0392b;--warn:#b06d00}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{background:var(--bg);color:#1c2530;font-size:14px;line-height:1.55;
 font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
.wrap{max-width:1640px;margin:0 auto;padding:14px 20px 40px}
.authline{background:linear-gradient(135deg,#8a2b2b,#c0392b);color:#fff;font-weight:800;font-size:15px;
 padding:12px 18px;border-radius:12px;margin:6px 0 14px;letter-spacing:.4px;box-shadow:0 2px 10px rgba(192,57,43,.22)}
.authline .sub{font-weight:400;font-size:12px;opacity:.92;display:block;margin-top:3px}
.card{background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 1px 4px rgba(27,79,156,.06);
 padding:12px 16px;margin-bottom:14px}
.cardhead{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.cardhead h2{font-size:15px;margin:0;font-weight:800;color:var(--dark)}
.cardhead h2:before{content:"";display:inline-block;width:4px;height:15px;border-radius:2px;
 background:linear-gradient(var(--pri),var(--pri2));margin-right:9px;vertical-align:-3px}
.cap{font-size:11.5px;color:var(--muted)}
.cardhead .cap{margin-left:auto}
.toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:9px;padding:11px 16px;background:#f7f9fd;
 border-bottom:1px solid var(--line);border-radius:12px 12px 0 0}
.card.np{padding:0;overflow:hidden}
button{font:inherit;cursor:pointer;border-radius:9px;padding:7px 14px;border:1px solid var(--line);background:#fff;color:var(--pri);font-weight:700}
button.pri{background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border:0}
button.danger{color:var(--bad);border-color:#e8c4c0}
button:disabled{opacity:.45;cursor:not-allowed}
select,input,textarea{font:inherit;border:1px solid #c8d2e0;border-radius:8px;padding:5px 8px;background:#fff;color:#1c2530}
input.num{text-align:right;font-variant-numeric:tabular-nums;width:96px}
input.txt{width:130px}
select.sm{padding:4px 6px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{border:1px solid var(--line);padding:6px 8px;vertical-align:middle}
thead th{background:#eef2f9;color:#26374a;font-weight:700;white-space:nowrap;position:sticky;top:0;z-index:2}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:ui-monospace,Menlo,monospace}
td.c,th.c{text-align:center}
tr.tot td{background:#eaf0fa;font-weight:800}
tr.sub td:first-child{padding-left:26px}
tr.sub td:first-child:before{content:"└ ";color:#9bb0d0}
.tblwrap{overflow:auto;max-height:66vh;-webkit-overflow-scrolling:touch}
.bad{color:var(--bad);font-weight:800}
.okc{color:var(--ok);font-weight:800}
.warnc{color:var(--warn);font-weight:800}
.pill{display:inline-block;padding:1px 9px;border-radius:10px;font-size:11.5px;font-weight:700}
.pill.ok{background:#e6f5ec;color:var(--ok)}.pill.bad{background:#fdecea;color:var(--bad)}
.pill.warn{background:#fdf3e0;color:var(--warn)}
.banner{padding:9px 14px;border-radius:9px;font-weight:700;margin-bottom:10px}
.banner.bad{background:#fdecea;border:1px solid #f0c2bc;color:var(--bad)}
.banner.ok{background:#e6f5ec;border:1px solid #b8e0c6;color:var(--ok)}
.mon{font-size:11.5px;color:#43536b}
.mon summary{cursor:pointer;color:var(--pri);font-weight:700}
.mon .grid{display:grid;grid-template-columns:repeat(6,minmax(64px,1fr));gap:3px 8px;margin-top:5px}
.mon .grid span{white-space:nowrap}
.mon .grid b{font-variant-numeric:tabular-nums}
#denied{margin:60px auto;max-width:660px;padding:26px 30px;border:1px solid #e3b7b7;border-radius:14px;
 background:#fff7f7;color:#8a2b2b;font-weight:700;font-size:16px;line-height:1.7;text-align:center}
.legend{font-size:12px;color:var(--muted);line-height:1.9}
"""

HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>目标基础数据管理台 · 宝锐生物</title>
<style>__CSS__</style>
</head>
<body>
<div class="wrap">
  <div class="authline" id="authline">
    【本表是目标数据的唯一权威源】改完 → 导出 JSON → 交工作区落盘 <code>数据/目标基础数据_2026.json</code> → 重跑 <code>脚本/refresh_sandept.sh</code> → 看板更新
    <span class="sub">本页仅 admin / gm 可见；数据经鉴权接口 /api/targets 服务端下发。不做直接写库（安全）。版本 __VERSION__</span>
  </div>

  <div id="app">
    <div id="denied">正在校验权限…</div>
  </div>

  <div class="card">
    <div class="cardhead"><h2>图例与口径</h2></div>
    <div class="legend">
      · <b>月度摊派规则</b>：<b>shape</b>＝个人月值 = 个人年度 × 部门(区域)当月 / Σ部门(区域)月；<b>even</b>＝年度/12；<b>manual</b>＝手填值优先（缺则 shape 兜底）。任一规则下<b>合计精确等于年度</b>（舍入差由 12 月吸收），<b>无源月保持 null 绝不写 0</b>。<br>
      · <b>三级闭合</b>：区域年度 = Σ区域内人；部门年度 = Σ人 + 未分摊（管理预留，未分到人）；全司年度 = Σ部门年度。不闭合时红色告警并<b>阻止导出</b>。<br>
      · <b>改动流程</b>：页面编辑 → 导出 JSON 落盘 → 重跑管道。本页不写库、不删旧源（kpi_scheme / 月度目标_2026统一 已降为历史）。
    </div>
  </div>
</div>

<script>
(function(){
'use strict';
var VERSION='__VERSION__';
var MONTHS=['2026-01','2026-02','2026-03','2026-04','2026-05','2026-06','2026-07','2026-08','2026-09','2026-10','2026-11','2026-12'];
var LS_DEPTS=['生命科学部'];
var RULE=(function(){var j='shape';try{var q=new RegExp('[?&]rule=([^&]*)').exec(location.search||'');if(q)j=decodeURIComponent(q[1]);}catch(e){}return j;})();
var S=null;            // 权威数据（镜像文件结构）
var LOCALKEY='bior_targets_manage_v1';
var deptDirty={};      // 部门 -> true（年度被改）
var allDirty=false;    // 规则切换 → 全部重算
var logRows=[];
var user={name:'',role:''};

/* ---------- 工具 ---------- */
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
function r2(x){return Math.round((+x||0)*100)/100;}
function fmt(x){if(x===null||x===undefined||x==='')return '—';return (Math.round(x*100)/100).toFixed(2);}
function isNum(v){return v!==''&&v!==null&&v!==undefined&&!isNaN(+v);}
function closeSum(o){var s=0;if(o)for(var k in o){if(typeof o[k]==='number')s+=o[k];}return r2(s);}
function nowStr(){var d=new Date();function p(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 logAct(kind,obj,detail){logRows.unshift({t:nowStr(),kind:kind,obj:obj,detail:detail,by:user.name||'—'});if(logRows.length>500)logRows.pop();}

/* ---------- 月度摊派规则（与 脚本/target_source.py taper 同实现） ---------- */
function taper(annual,shape,rule,manual){
  var months=(shape&&Object.keys(shape).length)?Object.keys(shape):MONTHS.slice();
  var vals={},i,m;
  if(annual===null||annual===undefined||annual===''||isNaN(+annual)){for(i=0;i<months.length;i++)vals[months[i]]=null;return vals;}
  annual=+annual;
  if(rule==='even'){for(i=0;i<months.length;i++)vals[months[i]]=r2(annual/months.length);}
  else{
    var tot=0;for(i=0;i<months.length;i++){var sv=shape?shape[months[i]]:null;if(typeof sv==='number')tot+=sv;}
    for(i=0;i<months.length;i++){
      m=months[i];
      var mv=(rule==='manual'&&manual)?manual[m]:null;
      if(mv!==null&&mv!==undefined&&mv!==''){vals[m]=r2(mv);continue;}
      var s2=shape?shape[m]:null;
      vals[m]=(typeof s2==='number'&&s2!==0&&tot>0)?r2(annual*s2/tot):null;
    }
  }
  var keys=months.filter(function(x){return vals[x]!==null&&vals[x]!==undefined;});
  if(keys.length){var s=0;for(i=0;i<keys.length-1;i++)s+=vals[keys[i]];vals[keys[keys.length-1]]=r2(annual-s);}
  return vals;
}
function shapeOf(p){
  var d=S.departments[p.dept]||{};
  var rg=(d.regions||{})[p.region];
  if(rg&&rg.target_monthly&&Object.keys(rg.target_monthly).length)return rg.target_monthly;
  return d.target_monthly||{};
}
function recomputePerson(p){
  if(p.target_annual===null||p.target_annual===undefined||p.target_annual===''){p.target_monthly=null;return;}
  p.target_monthly=taper(p.target_annual,shapeOf(p),RULE,p.monthly_manual||null);
}
function recomputeAll(force){
  S.people.forEach(function(p){
    if(force||allDirty||p.__dirty)recomputePerson(p);
  });
}

/* ---------- 闭合校验 ---------- */
function closure(){
  var issues=[],depts=S.departments||{};
  var grand=0;
  var deptStat={};
  Object.keys(depts).forEach(function(dn){
    var dv=depts[dn],da=+dv.target_annual||0;
    grand+=da;
    var dmSum=closeSum(dv.target_monthly||{});
    var ppl=S.people.filter(function(p){return p.dept===dn&&p.count_in_stats&&p.target_annual!==null&&p.target_annual!==undefined&&p.target_annual!=='';});
    var psum=r2(ppl.reduce(function(a,p){return a+(+p.target_annual||0);},0));
    var un=+(dv.unallocated||0);
    deptStat[dn]={annual:da,monthly_sum:dmSum,people_sum:psum,people_n:ppl.length,unallocated:un};
    if(Math.abs(dmSum-da)>0.05)issues.push('部门「'+dn+'」月度合计 '+fmt(dmSum)+' ≠ 年度 '+fmt(da));
    if(Math.abs(da-psum-un)>0.05)issues.push('部门「'+dn+'」年度 '+fmt(da)+' ≠ Σ人 '+fmt(psum)+' + 未分摊 '+fmt(un));
    var regs=dv.regions||{};
    var regSum=0;
    Object.keys(regs).forEach(function(rg){
      var rv=regs[rg],ra=+rv.target_annual||0;regSum+=ra;
      var rp=S.people.filter(function(p){return p.dept===dn&&p.region===rg&&p.count_in_stats&&p.target_annual!==null&&p.target_annual!==undefined&&p.target_annual!=='';});
      var rps=r2(rp.reduce(function(a,p){return a+(+p.target_annual||0);},0));
      if(Math.abs(ra-rps)>0.05)issues.push('区域「'+dn+'/'+rg+'」年度 '+fmt(ra)+' ≠ Σ区域内人 '+fmt(rps));
      if(Math.abs(closeSum(rv.target_monthly||{})-ra)>0.05)issues.push('区域「'+dn+'/'+rg+'」月度合计 ≠ 年度 '+fmt(ra));
    });
    if(Object.keys(regs).length&&Math.abs(regSum-da)>0.05)issues.push('部门「'+dn+'」区域合计 '+fmt(regSum)+' ≠ 部门年度 '+fmt(da));
  });
  if(Math.abs(grand-r2(Object.keys(depts).reduce(function(a,d){return a+(+depts[d].target_annual||0);},0)))>1e-6){/*noop*/}
  // 必填校验
  S.people.forEach(function(p,i){
    if(!p.name||!String(p.name).trim())issues.push('第 '+(i+1)+' 行：姓名不能为空');
    if(!p.dept||!String(p.dept).trim())issues.push('第 '+(i+1)+' 行（'+(p.name||'?')+'）：部门不能为空');
    if(p.target_annual!==null&&p.target_annual!==undefined&&p.target_annual!==''&&!isNum(p.target_annual))issues.push('行「'+(p.name||('#'+(i+1)))+'」年度目标非数字');
  });
  return {ok:issues.length===0,issues:issues,deptStat:deptStat,grand:r2(grand)};
}

/* ---------- 渲染 ---------- */
function deptOptions(){return ['大客户部','销售拓展部','生命科学部'].concat(Object.keys(S.departments).filter(function(d){return ['大客户部','销售拓展部','生命科学部'].indexOf(d)<0;}));}
function regionOptions(dept){var d=S.departments[dept]||{};var r=Object.keys(d.regions||{});return r.length?r.concat(['']):['广东','浙江',''];}
function nameOptions(){return ['—'].concat(S.people.map(function(p){return p.name;}).filter(Boolean));}

function renderAll(){
  recomputeAll(false);
  var cl=closure();
  var app=document.getElementById('app');
  app.innerHTML=deptTableHTML(cl)+peopleCardHTML(cl)+logCardHTML();
  bindDept();bindPeople();bindTop();
}
function deptTableHTML(cl){
  var h='<div class="card np"><div class="toolbar"><b>① 部门 / 区域汇总</b><span class="cap">年度目标可改（区域年度=Σ人；部门年度=Σ人+未分摊；全司=Σ部门）</span>'
    +'<span style="margin-left:auto">全司年度合计 <b id="grandTxt">'+fmt(cl.grand)+'</b> 万</span></div>'
    +'<div class="tblwrap"><table><thead><tr><th>层级</th><th>名称</th><th class="n">年度目标(万)</th><th class="n">12月目标合计</th><th class="n">Σ人年度</th><th class="n">未分摊</th><th class="c">闭合</th></tr></thead><tbody>';
  Object.keys(S.departments).forEach(function(dn,di){
    var dv=S.departments[dn],st=cl.deptStat[dn];
    var okA=Math.abs(st.annual-st.people_sum-st.unallocated)<=0.05;
    var okM=Math.abs(st.monthly_sum-st.annual)<=0.05;
    h+='<tr class="tot"><td>部门</td><td>'+esc(dn)+'</td>'
      +'<td class="n"><input class="num" data-dept="'+esc(dn)+'" value="'+fmt(dv.target_annual)+'"></td>'
      +'<td class="n" id="dms_'+di+'">'+fmt(st.monthly_sum)+'</td><td class="n" id="dps_'+di+'">'+fmt(st.people_sum)+' <span class="cap">('+st.people_n+'人)</span></td>'
      +'<td class="n"><input class="num" data-dept-un="'+esc(dn)+'" value="'+fmt(dv.unallocated||0)+'"></td>'
      +'<td class="c" id="dpill_'+di+'"><span class="pill '+(okA&&okM?'ok':'bad')+'">'+(okA&&okM?'闭合':'不闭合')+'</span></td></tr>';
    Object.keys(dv.regions||{}).forEach(function(rg,ri){
      var rv=dv.regions[rg];
      var rp=S.people.filter(function(p){return p.dept===dn&&p.region===rg&&p.count_in_stats&&p.target_annual!==null&&p.target_annual!==undefined&&p.target_annual!=='';});
      var rps=r2(rp.reduce(function(a,p){return a+(+p.target_annual||0);},0));
      var ok=Math.abs((+rv.target_annual||0)-rps)<=0.05&&Math.abs(closeSum(rv.target_monthly||{})-(+rv.target_annual||0))<=0.05;
      h+='<tr class="sub"><td>区域</td><td>'+esc(rg)+' <span class="cap">经理 '+(rv.manager||'')+'</span></td>'
        +'<td class="n"><input class="num" data-region="'+esc(dn)+'|'+esc(rg)+'" value="'+fmt(rv.target_annual)+'"></td>'
        +'<td class="n" id="rms_'+di+'_'+ri+'">'+fmt(closeSum(rv.target_monthly||{}))+'</td><td class="n" id="rps_'+di+'_'+ri+'">'+fmt(rps)+' <span class="cap">('+rp.length+'人)</span></td>'
        +'<td class="n">—</td><td class="c" id="rpill_'+di+'_'+ri+'"><span class="pill '+(ok?'ok':'bad')+'">'+(ok?'闭合':'不闭合')+'</span></td></tr>';
    });
  });
  h+='</tbody></table></div></div>';
  return h;
}
function peopleCardHTML(cl){
  var h='<div class="card np"><div class="toolbar"><b>② 人员表</b>'
    +'<span class="cap">共 <b id="pn">'+S.people.length+'</b> 人（含离职保留在册）</span>'
    +'<label style="margin-left:auto" class="cap">月度摊派规则 '
    +'<select id="ruleSel" class="sm"><option value="shape">shape 原形态分摊</option><option value="even">even 平均</option><option value="manual">manual 手填优先</option></select></label>'
    +'<button id="btnNew">＋ 新建销售员</button><button id="btnJson" class="pri">导出 JSON</button><button id="btnCsv">导出 CSV</button><button id="btnReset" class="danger">重置</button></div>'
    +'<div class="tblwrap"><table><thead><tr>'
    +'<th>姓名</th><th>部门</th><th>所属区域</th><th>岗位名</th><th class="n">年度目标(万)</th><th>月度目标</th>'
    +'<th>在职状态</th><th class="c">计入统计</th><th>离职归属经理</th><th>备注</th><th class="c">操作</th></tr></thead><tbody id="pb">';
  S.people.forEach(function(p,i){h+=personRowHTML(p,i);});
  h+='</tbody></table></div>'
    +'<div style="padding:10px 16px" id="closeBox">'+closureBoxHTML(cl)+'</div></div>';
  return h;
}
function personRowHTML(p,i){
  var deptOpts=deptOptions().map(function(d){return '<option value="'+esc(d)+'"'+(d===p.dept?' selected':'')+'>'+esc(d)+'</option>';}).join('');
  var regOpts=regionOptions(p.dept).map(function(r){return '<option value="'+esc(r)+'"'+(r===p.region?' selected':'')+'>'+esc(r||'（无）')+'</option>';}).join('');
  var stOpts=['在职','离职','新增'].map(function(s){return '<option value="'+esc(s)+'"'+(s===p.status?' selected':'')+'>'+esc(s)+'</option>';}).join('');
  var mgrOpts=nameOptions().map(function(n){return '<option value="'+esc(n==='—'?'':n)+'"'+(((p.merged_into||'')===(n==='—'?'':n))?' selected':'')+'>'+esc(n)+'</option>';}).join('');
  var mv=p.target_monthly?(closeSum(p.target_monthly)):null;
  return '<tr data-i="'+i+'">'
    +'<td><input class="txt" data-f="name" value="'+esc(p.name)+'"></td>'
    +'<td><select class="sm" data-f="dept">'+deptOpts+'</select></td>'
    +'<td><select class="sm" data-f="region">'+regOpts+'</select></td>'
    +'<td><input class="txt" data-f="post" value="'+esc(p.post||'')+'"></td>'
    +'<td class="n"><input class="num" data-f="target_annual" value="'+(p.target_annual===null||p.target_annual===undefined?'':fmt(p.target_annual))+'"></td>'
    +'<td class="mon" data-mon="'+i+'">'+monCellHTML(p)+'</td>'
    +'<td><select class="sm" data-f="status">'+stOpts+'</select></td>'
    +'<td class="c"><input type="checkbox" data-f="count_in_stats"'+(p.count_in_stats?' checked':'')+'></td>'
    +'<td><select class="sm" data-f="merged_into">'+mgrOpts+'</select></td>'
    +'<td><input class="txt" style="width:180px" data-f="note" value="'+esc(p.note||'')+'"></td>'
    +'<td class="c"><button class="danger" data-del="'+i+'">删</button></td></tr>';
}
function monCellHTML(p){
  if(!p.target_monthly)return '<span class="cap">无（年度目标为空→不摊）</span>';
  var g=MONTHS.map(function(m){return '<span>'+m.slice(5)+' <b>'+fmt(p.target_monthly[m])+'</b></span>';}).join('');
  return '<details><summary>合计 '+fmt(closeSum(p.target_monthly))+' 万 · 展开12月</summary><div class="grid">'+g+'</div></details>';
}
function closureBoxHTML(cl){
  if(cl.ok)return '<div class="banner ok">✅ 三级闭合通过：区域=Σ人 · 部门=Σ人+未分摊 · 全司=Σ部门。可导出。</div>';
  return '<div class="banner bad">⛔ 未闭合，已阻止导出：<br>· '+cl.issues.map(esc).join('<br>· ')+'</div>';
}
function logCardHTML(){
  var h='<div class="card"><div class="cardhead"><h2>③ 变更日志</h2><span class="cap">本机编辑记录（localStorage 暂存；导出前请核对）</span></div>'
    +'<div class="tblwrap" style="max-height:34vh"><table><thead><tr><th>时间</th><th>类型</th><th>对象</th><th>变更</th><th>操作人</th></tr></thead><tbody>';
  if(!logRows.length)h+='<tr><td colspan="5" class="cap" style="text-align:center">暂无变更</td></tr>';
  logRows.forEach(function(r){h+='<tr><td>'+esc(r.t)+'</td><td>'+esc(r.kind)+'</td><td>'+esc(r.obj)+'</td><td>'+esc(r.detail)+'</td><td>'+esc(r.by)+'</td></tr>';});
  h+='</tbody></table></div></div>';
  return h;
}

/* ---------- 事件绑定 ---------- */
var composing=false;
function setTxt(id,html){var e=document.getElementById(id);if(e)e.innerHTML=html;}
function refreshSummary(){
  var cl=closure();
  setTxt('grandTxt',fmt(cl.grand));
  var pn=document.getElementById('pn');if(pn)pn.textContent=S.people.length;
  Object.keys(S.departments).forEach(function(dn,di){
    var st=cl.deptStat[dn];
    var okA=Math.abs(st.annual-st.people_sum-st.unallocated)<=0.05;
    var okM=Math.abs(st.monthly_sum-st.annual)<=0.05;
    setTxt('dms_'+di,fmt(st.monthly_sum));
    setTxt('dps_'+di,fmt(st.people_sum)+' <span class="cap">('+st.people_n+'人)</span>');
    setTxt('dpill_'+di,'<span class="pill '+(okA&&okM?'ok':'bad')+'">'+(okA&&okM?'闭合':'不闭合')+'</span>');
    Object.keys(S.departments[dn].regions||{}).forEach(function(rg,ri){
      var rv=S.departments[dn].regions[rg];
      var rp=S.people.filter(function(p){return p.dept===dn&&p.region===rg&&p.count_in_stats&&p.target_annual!==null&&p.target_annual!==undefined&&p.target_annual!=='';});
      var rps=r2(rp.reduce(function(a,p){return a+(+p.target_annual||0);},0));
      var ok=Math.abs((+rv.target_annual||0)-rps)<=0.05&&Math.abs(closeSum(rv.target_monthly||{})-(+rv.target_annual||0))<=0.05;
      setTxt('rms_'+di+'_'+ri,fmt(closeSum(rv.target_monthly||{})));
      setTxt('rps_'+di+'_'+ri,fmt(rps)+' <span class="cap">('+rp.length+'人)</span>');
      setTxt('rpill_'+di+'_'+ri,'<span class="pill '+(ok?'ok':'bad')+'">'+(ok?'闭合':'不闭合')+'</span>');
    });
  });
  var cb=document.getElementById('closeBox');if(cb)cb.innerHTML=closureBoxHTML(cl);
  var bj=document.getElementById('btnJson');if(bj)bj.disabled=!cl.ok;
  var bc=document.getElementById('btnCsv');if(bc)bc.disabled=!cl.ok;
}
function updMonCell(i){
  var cell=document.querySelector('[data-mon="'+i+'"]');
  if(cell)cell.innerHTML=monCellHTML(S.people[i]);
}
function bindDept(){
  document.querySelectorAll('[data-dept]').forEach(function(inp){
    inp.addEventListener('input',function(){if(composing)return;
      var dn=inp.getAttribute('data-dept');var v=inp.value.trim();
      var dv=S.departments[dn];var old=dv.target_annual;
      dv.target_annual=isNum(v)?+v:null;
      if(dv.target_annual!==null && !deptDirty[dn]){dv.target_monthly=taper(dv.target_annual,dv.target_monthly,RULE,dv.monthly_manual||null);}
      deptDirty[dn]=true;
      logAct('部门','年度目标',dn+'：'+fmt(old)+' → '+fmt(dv.target_annual));
      // 重算该部门人员的月度形态
      S.people.forEach(function(p){if(p.dept===dn)recomputePerson(p);});
      saveLocal();refreshSummary();
    });
  });
  document.querySelectorAll('[data-dept-un]').forEach(function(inp){
    inp.addEventListener('input',function(){if(composing)return;
      var dn=inp.getAttribute('data-dept-un');var v=inp.value.trim();
      var dv=S.departments[dn];var old=dv.unallocated;
      dv.unallocated=isNum(v)?+v:0;
      logAct('部门','未分摊',dn+'：'+fmt(old)+' → '+fmt(dv.unallocated));
      saveLocal();refreshSummary();
    });
  });
  document.querySelectorAll('[data-region]').forEach(function(inp){
    inp.addEventListener('input',function(){if(composing)return;
      var k=inp.getAttribute('data-region').split('|');var dv=S.departments[k[0]];var rv=dv.regions[k[1]];
      var old=rv.target_annual;var v=inp.value.trim();
      rv.target_annual=isNum(v)?+v:null;
      if(rv.target_annual!==null){rv.target_monthly=taper(rv.target_annual,rv.target_monthly,RULE,rv.monthly_manual||null);}
      logAct('区域','年度目标',k[0]+'/'+k[1]+'：'+fmt(old)+' → '+fmt(rv.target_annual));
      S.people.forEach(function(p){if(p.dept===k[0]&&p.region===k[1])recomputePerson(p);});
      saveLocal();refreshSummary();
    });
  });
}
function bindPeople(){
  var rs=document.getElementById('ruleSel');if(rs){rs.value=RULE;
    rs.addEventListener('change',function(){RULE=rs.value;allDirty=true;logAct('规则','月度摊派','切换为 '+RULE);
      document.querySelectorAll('[data-mon]').forEach(function(c,i){recomputePerson(S.people[i]);c.innerHTML=monCellHTML(S.people[i]);});
      saveLocal();refreshSummary();});}
  var bn=document.getElementById('btnNew');if(bn)bn.addEventListener('click',newPerson);
  var bj=document.getElementById('btnJson');if(bj)bj.addEventListener('click',exportJSON);
  var bc=document.getElementById('btnCsv');if(bc)bc.addEventListener('click',exportCSV);
  var br=document.getElementById('btnReset');if(br)br.addEventListener('click',resetAll);
  document.querySelectorAll('#pb tr').forEach(function(tr){
    var i=+tr.getAttribute('data-i');
    tr.querySelectorAll('[data-f]').forEach(function(el){
      var f=el.getAttribute('data-f');
      var ev=(el.tagName==='SELECT'||el.type==='checkbox')?'change':'input';
      el.addEventListener(ev,function(){
        if(composing)return;
        var p=S.people[i];var old=p[f];
        if(el.type==='checkbox')p[f]=el.checked;
        else if(f==='target_annual'){var v=el.value.trim();p[f]=isNum(v)?+v:(v===''?null:NaN);}
        else p[f]=el.value;
        if(f==='target_annual'){p.__dirty=true;recomputePerson(p);updMonCell(i);}
        if(f==='dept'||f==='region'){p.__dirty=true;recomputePerson(p);updMonCell(i);}
        if(f==='status'){p.count_in_stats=(p.status!=='离职');var cb=tr.querySelector('[data-f=count_in_stats]');if(cb)cb.checked=p.count_in_stats;}
        logAct('人员',f,(p.name||('#'+i))+'：'+String(old)+' → '+String(p[f]));
        saveLocal();refreshSummary();
      });
    });
    var dl=tr.querySelector('[data-del]');if(dl)dl.addEventListener('click',function(){
      var p=S.people[i];if(!confirm('删除人员「'+(p.name||('#'+i))+'」？（仅本页编辑，导出后生效）'))return;
      logAct('人员','删除',(p.name||('#'+i))+'（部门 '+(p.dept||'')+'）');
      S.people.splice(i,1);saveLocal();renderAll();
    });
  });
  document.querySelectorAll('#pb input,#pb select').forEach(function(el){
    el.addEventListener('compositionstart',function(){composing=true;});
    el.addEventListener('compositionend',function(){composing=false;});
  });
}
function bindTop(){}

/* ---------- 新建 / 删除 ---------- */
function newPerson(){
  S.people.push({name:'',dept:'生命科学部',region:'广东',post:'',target_annual:null,target_monthly:null,
    status:'在职',count_in_stats:true,merged_into:null,manager_shortfall:null,note:'（新建，请填姓名/岗位名/年度目标）'});
  logAct('人员','新建','新增一行（默认 生命科学部/广东）');
  saveLocal();renderAll();
  var rows=document.querySelectorAll('#pb tr');var last=rows[rows.length-1];if(last)last.querySelector('[data-f=name]').focus();
}

/* ---------- 导出 ---------- */
function buildJSON(){
  recomputeAll(false);
  var sd=JSON.parse(JSON.stringify(S));
  sd.people=(sd.people||[]).map(function(p){
    var o={};Object.keys(p).forEach(function(k){if(k.indexOf('__')!==0)o[k]=p[k];});return o;
  });
  sd.meta=sd.meta||{};
  sd.meta.authoritative=true;
  sd.meta.generated=new Date().toISOString();
  sd.meta.monthly_rule=RULE;
  sd.meta.author='目标基础数据管理台（页面导出）';
  sd.meta.supersedes=['数据/kpi_scheme.json','数据/月度目标_2026统一.json'];
  sd.meta.months=MONTHS.slice();
  sd.meta.scale='万元';
  sd.meta.total_annual=r2(Object.keys(sd.departments).reduce(function(a,d){return a+(+sd.departments[d].target_annual||0);},0));
  sd.meta.closure_rule='区域年度 = Σ区域内人；部门年度 = Σ人 + 未分摊；全司年度 = Σ部门年度。';
  sd.meta.note='本文件是目标数据的唯一权威源（由管理台导出）。';
  return sd;
}
function tsStr(){var d=new Date();function p(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 download(name,text,mime){
  var b=new Blob([text],{type:mime||'application/json;charset=utf-8'});
  var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=name;
  document.body.appendChild(a);a.click();setTimeout(function(){URL.revokeObjectURL(u);a.remove();},400);
}
function exportJSON(){
  var cl=closure();if(!cl.ok){alert('未闭合，已阻止导出：\n'+cl.issues.join('\n'));return;}
  var sd=buildJSON();
  download('目标基础数据_2026_'+tsStr()+'.json',JSON.stringify(sd,null,1),'application/json;charset=utf-8');
  window.__lastExport=sd;
  logAct('导出','JSON','目标基础数据_2026_'+tsStr()+'.json（'+S.people.length+'人）');
}
function exportCSV(){
  var cl=closure();if(!cl.ok){alert('未闭合，已阻止导出：\n'+cl.issues.join('\n'));return;}
  var head=['姓名','部门','所属区域','岗位名','年度目标','在职状态','计入统计','离职归属经理','月度合计','备注'];
  var lines=[head.join(',')];
  S.people.forEach(function(p){
    var row=[p.name,p.dept,p.region,p.post,(p.target_annual===null?'':p.target_annual),p.status,p.count_in_stats?'是':'否',
      (p.merged_into||''),(p.target_monthly?fmt(closeSum(p.target_monthly)):''),p.note||''];
    lines.push(row.map(function(x){x=String(x==null?'':x);return /[",\n]/.test(x)?'"'+x.replace(/"/g,'""')+'"':x;}).join(','));
  });
  download('目标基础数据_2026_人员_'+tsStr()+'.csv','\ufeff'+lines.join('\r\n'),'text/csv;charset=utf-8');
  logAct('导出','CSV','人员表 '+S.people.length+' 行');
}

/* ---------- 本地暂存 / 重置 ---------- */
function saveLocal(){try{localStorage.setItem(LOCALKEY,JSON.stringify({S:S,rule:RULE,log:logRows.slice(0,200)}));}catch(e){}}
function loadLocal(){try{var s=localStorage.getItem(LOCALKEY);return s?JSON.parse(s):null;}catch(e){return null;}}
function resetAll(){
  if(!confirm('重置：丢弃本机所有编辑与暂存，重新从服务端拉取权威源？'))return;
  try{localStorage.removeItem(LOCALKEY);}catch(e){}
  location.reload();
}

/* ---------- 引导 ---------- */
function token(){try{var q=location.search||'';var m=/[?&](?:t|token)=([^&]*)/.exec(q);if(m)return decodeURIComponent(m[1]);
  var s=sessionStorage.getItem('bior_sandept_token');if(s)return s;
  return localStorage.getItem('bior_auth_token')||'';}catch(e){return '';}}
var ABS=['https://api.biorisales.site:8443','https://api.biorisales.site'];
function bases(){var l=[];try{var h=(location.hostname||'').toLowerCase();
  if(location.protocol!=='http:'&&location.protocol!=='https:')return l;
  if(h==='127.0.0.1'||h==='localhost'||/\.ts\.net$/.test(h))l.push('');else ABS.forEach(function(x){l.push(x);});}catch(e){}return l;}
function denied(msg){var app=document.getElementById('app');if(app)app.innerHTML='<div id="denied">⛔ '+esc(msg)+
  '<div style="margin-top:10px;font-weight:400;font-size:13px;color:#9b6a6a">本页仅 admin / gm 可访问；数据由服务端按角色过滤下发，静态页面不含任何姓名/金额。</div></div>';}
function boot(){
  var bs=bases();if(!bs.length){denied('请通过工作台（登录态）访问本页');return;}
  var tk=token();if(!tk){denied('未登录或无权访问');return;}
  var i=0;
  (function attempt(){
    if(i>=bs.length){denied('数据加载失败（网络异常）');return;}
    var base=bs[i++];
    fetch(base+'/api/targets',{method:'GET',cache:'no-store',headers:{'Authorization':'Bearer '+tk}})
      .then(function(r){
        if(r.status===401||r.status===403){denied(r.status===403?'无权限（仅 admin / gm）':'未登录或登录已过期');return;}
        if(!r.ok)return attempt();
        return r.json().then(function(j){
          if(!j||!j.ok||!j.data){return attempt();}
          S=j.data;user=j.user||{};
          S.departments=S.departments||{};S.people=S.people||[];S.meta=S.meta||{};
          if(S.meta.monthly_rule&&!/([?&]rule=)/.test(location.search||''))RULE=S.meta.monthly_rule;
          var loc=loadLocal();
          if(loc&&loc.S){
            if(confirm('检测到本机有未导出的暂存编辑（'+loc.S.people.length+' 人）。点击「确定」恢复暂存，点击「取消」用服务端最新权威源覆盖。')){
              S=loc.S;RULE=loc.rule||RULE;logRows=loc.log||[];allDirty=true;
            }
          }
          logAct('加载','权威源','来自 /api/targets（'+(S.meta.generated||'')+'）');
          renderAll();
        });
      })
      .catch(function(){return attempt();});
  })();
}
window.__TMS={get S(){return S;},closure:closure,newPerson:newPerson,buildJSON:buildJSON,get rule(){return RULE;},set rule(v){RULE=v;allDirty=true;}};
try{boot();}catch(e){denied('初始化异常：'+e.message);}
})();
</script>
</body>
</html>
"""


def main():
    html = HTML.replace('__CSS__', CSS).replace('__VERSION__', VERSION)
    html = BK.inject(html, theme='light', tag='目标基础数据 · 管理台')
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    with io.open(OUT, 'w', encoding='utf-8') as f:
        f.write(html)
    v = BK.verify(html)
    print('已写出', OUT, '（%d 字节）' % len(html.encode('utf-8')))
    print('brand 自检:', v)
    # 零外链：不得出现 <link>/<script src>/外链 src=
    import re
    ext = re.findall(r'<(?:link|script|img|iframe)[^>]*\b(?:src|href)=["\']https?://', html)
    print('外链资源(应为0):', len(ext))


if __name__ == '__main__':
    main()
