#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""报销助手 · 生成自包含可编辑页面 → 工具/报销助手.html

页面能力（提交方视角）：
  ① 台账明细：可编辑（费用类别 / 关联客户 / 陪同人数 / 备注）→ 改动即时存 localStorage
  ② 校验结果：状态徽章 + 问题原因（悬停/展开）
  ③ 待补清单：缺字段、缺关联客户、规则未配置
  ④ 汇总：按费用类别 / 按月
  ⑤ 导出：保存 / 导出带数据 HTML / 导出 JSON / 导出 CSV / 打印 / 重置
"""
import os
import sys
import json
import base64
import datetime

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import invoice_core as core  # noqa: E402

LOGO = os.path.expanduser('~/work/公司资料/logo_transparent.png')


def logo_b64():
    if os.path.exists(LOGO):
        return 'data:image/png;base64,' + base64.b64encode(open(LOGO, 'rb').read()).decode()
    return ''


def unconfigured(rules):
    """列出真正没配置、导致判不了的问题项（字段名须与 规则库 v2 对齐）"""
    out = []
    ci = rules.get('公司开票信息') or {}
    if not core.is_configured(ci.get('名称')):
        out.append('公司抬头（规则库 › 公司开票信息 › 名称）→ 无法判定抬头是否开错')
    if not core.is_configured(ci.get('税号')):
        out.append('公司税号 → 无法判定税号是否开错')

    lim = rules.get('标准与限额') or {}
    h = lim.get('差旅住宿') or {}
    tiers = [k for k in h if isinstance(h.get(k), dict) and '上限' in (h.get(k) or {})]
    if not tiers or not any(core.is_configured((h[k] or {}).get('上限')) for k in tiers):
        out.append('差旅住宿上限（超一类/一类/二类）→ 无法判定住宿超标')
    else:
        miss = [k for k in tiers if not core.is_configured((h[k] or {}).get('上限'))]
        if miss:
            out.append('差旅住宿上限缺：%s → 该档位无法判定' % '、'.join(miss))

    b = lim.get('业务招待') or {}
    if not core.is_configured(b.get('餐厅招待_人均上限')):
        out.append('业务招待·餐厅人均上限 → 无法判定餐厅招待超标')
    if not core.is_configured(b.get('外卖点餐_人均上限')):
        out.append('业务招待·外卖人均上限 → 无法判定外卖超标')
    if not core.is_configured((lim.get('出差补助') or {}).get('标准')):
        out.append('出差补助标准 → 无法核对补助金额')
    if not core.is_configured((lim.get('目的地交通费') or {}).get('上限')):
        out.append('目的地交通费上限 → 无法判定日均超标')
    p = lim.get('私车公用') or {}
    if not core.is_configured((p.get('燃油费_油车') or {}).get('标准')) and \
       not core.is_configured((p.get('燃油费_电车') or {}).get('标准')):
        out.append('私车公用燃油费标准 → 无法核对燃油费')

    rng = ((rules.get('发票合规规则') or {}).get('开票日期允许范围') or {})
    if not core.is_configured(rng.get('最早')):
        out.append('开票日期允许范围 → 无法判定跨年度票据')
    return out


CSS = """
*{box-sizing:border-box}
body{margin:0;background:#eef2f7;color:#1f2d3d;font:14px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.page{max-width:1500px;margin:14px auto;background:#fff;padding:0 0 40px;box-shadow:0 2px 14px rgba(16,42,80,.1)}
.topbar{background:linear-gradient(135deg,#0f2b47,#1a73c9);color:#fff;padding:12px 20px;display:flex;align-items:center;gap:12px}
.topbar img{height:34px}
.topbar .t{font-weight:800;font-size:16px}
.topbar .t small{display:block;font-weight:500;font-size:10.5px;opacity:.85;letter-spacing:1.2px}
.topbar .rt{margin-left:auto;font-size:11.5px;opacity:.9;text-align:right}
.wrap{padding:0 20px}
h2{font-size:15.5px;color:#12325b;margin:20px 0 8px;padding-left:9px;border-left:4px solid #1a73c9}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-top:16px}
.kpi{border:1px solid #dbe6f3;border-radius:12px;padding:12px 14px;background:#fff}
.kpi .n{font-size:23px;font-weight:800;color:#12325b;line-height:1.2}
.kpi .l{font-size:12px;color:#7286a0;margin-top:2px}
.kpi.bad .n{color:#c0392b}.kpi.warn .n{color:#a8641a}.kpi.ok .n{color:#0d7a47}
.kpi.bad{background:#fdeaea;border-color:#f2c4c4}.kpi.warn{background:#fff8e6;border-color:#f0d080}
.bad-box{background:#fdeaea;border:1px solid #f2c4c4;border-left:4px solid #e05252;border-radius:8px;padding:10px 13px;font-size:12.6px;margin:8px 0;color:#8c2626}
.bad-box b{color:#b52c2c}
.jump{color:#1a73c9;cursor:pointer;font-weight:700;text-decoration:underline}
tr.hit td{background:#fff6d8!important}
td.dt{white-space:nowrap}
td.ell{max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
table{width:100%;border-collapse:collapse;font-size:12.8px}
th{background:#f2f7fd;color:#12325b;text-align:left;padding:7px 8px;border:1px solid #dbe6f3;font-weight:600;position:sticky;top:0;z-index:2}
td{border:1px solid #dbe6f3;padding:5px 8px;vertical-align:top}
tr:hover td{background:#fafcff}
.sel,.inp{border:1px solid #dbe6f3;border-radius:6px;padding:3px 6px;font-size:12.4px;font-family:inherit;background:#fff;width:100%}
.sel:focus,.inp:focus{outline:none;border-color:#1a73c9;box-shadow:0 0 0 2px rgba(26,115,201,.12)}
.badge{display:inline-block;border-radius:10px;padding:1px 8px;font-size:11.5px;white-space:nowrap}
.b-通过{background:#e2f6ec;color:#0d7a47}.b-待补{background:#fdf0dc;color:#a8641a}
.b-异常{background:#fdeaea;color:#b52c2c}.b-重复{background:#ece6fb;color:#5b3ba8}
.b-待核{background:#e3eefb;color:#1a5fa8}.b-未校验{background:#eef1f5;color:#7286a0}
.issues{color:#b52c2c;font-size:11.8px}
.notes{color:#8a94a6;font-size:11.5px}
.bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:10px 0}
.bar input,.bar select{border:1px solid #dbe6f3;border-radius:8px;padding:6px 10px;font-size:12.8px;font-family:inherit}
.tb{display:flex;gap:8px;flex-wrap:wrap}
.tb button{background:#fff;border:1px solid #cfe0f2;color:#1852B6;border-radius:8px;padding:6px 13px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}
.tb button:hover{background:#eef4fd;border-color:#1a73c9}
.tb button.pri{background:#1a73c9;border-color:#1a73c9;color:#fff}
.note{background:#eef6ff;border:1px solid #cfe0f2;border-left:4px solid #1a73c9;border-radius:8px;padding:10px 13px;font-size:12.6px;margin:10px 0}
.warn{background:#fff8e6;border:1px solid #f0d080;border-radius:8px;padding:10px 13px;font-size:12.6px;margin:10px 0;color:#7a5200}
.warn b{color:#8a5a00}
details{border:1px solid #dbe6f3;border-radius:9px;padding:10px 13px;background:#fbfdff;margin:10px 0}
details summary{font-weight:700;color:#12325b;cursor:pointer;font-size:13.5px}
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:900px){.two{grid-template-columns:1fr}}
.foot{font-size:11.4px;color:#8a94a6;border-top:1px solid #e7eef6;margin-top:18px;padding-top:10px}
.msg{position:fixed;right:18px;bottom:82px;background:#12325b;color:#fff;padding:9px 16px;border-radius:20px;font-size:13px;z-index:99;display:none}
.fixedtb{position:fixed;right:16px;bottom:18px;z-index:98;display:flex;gap:8px;flex-wrap:wrap;max-width:70vw;justify-content:flex-end}
.fixedtb button{background:#fff;border:1px solid #cfe0f2;color:#1852B6;border-radius:20px;padding:8px 15px;font-size:12.8px;font-weight:700;cursor:pointer;box-shadow:0 4px 14px rgba(16,42,80,.12);font-family:inherit}
.fixedtb button.pri{background:#1a73c9;border-color:#1a73c9;color:#fff}
"""

JS = """
var STORE_KEY='baoyu_expense_assistant_v1';
var LOCK=false;
function $(s){return document.querySelector(s);}
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function money(v){return (v==null||isNaN(v))?'—':Number(v).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});}
function toast(m){var e=$('#msg');e.textContent=m;e.style.display='block';clearTimeout(window.__tt);window.__tt=setTimeout(function(){e.style.display='none';},1900);}

/* 合并本地改动（仅覆盖用户可编辑字段） */
function mergeLocal(){
  var raw=null;try{raw=localStorage.getItem(STORE_KEY);}catch(e){}
  if(!raw)return;
  try{
    var d=JSON.parse(raw);
    state.发票.forEach(function(r){
      var o=d[r.id];if(!o)return;
      r.分类=r.分类||{};r.补录=r.补录||{};
      ['费用类别','关联客户','陪同人数','备注','关联项目'].forEach(function(k){ if(o[k]!==undefined) r.分类[k]=o[k]; });
      Object.keys(o.补录||{}).forEach(function(k){ r.补录[k]=o.补录[k]; });
      Object.keys(o.票面||{}).forEach(function(k){ r.票面=r.票面||{}; r.票面[k]=o.票面[k]; });
      if(o._status) r.统计状态=o._status;
    });
  }catch(e){}
}
function saveLocal(){
  var d={};
  state.发票.forEach(function(r){
    d[r.id]={费用类别:(r.分类||{}).费用类别||'',关联客户:(r.分类||{}).关联客户||'',
             陪同人数:(r.分类||{}).陪同人数||'',备注:(r.分类||{}).备注||'',关联项目:(r.分类||{}).关联项目||'',
             补录:r.补录||{},票面:r.票面||{}};
  });
  try{localStorage.setItem(STORE_KEY,JSON.stringify(d));toast('✅ 已保存到本机浏览器');}catch(e){toast('❌ 保存失败（存储已满）');}
}
function rowStatus(r){return r.统计状态||(r.校验||{}).状态||'未校验';}
function issues(r){return ((r.校验||{}).问题)||[];}

var CATS=__CATS__;
function renderRows(){
  var cat=$('#fCat').value, st=$('#fSt').value, mo=$('#fMo').value, kw=$('#fKw').value.trim();
  var rows=state.发票.filter(function(r){
    var rs=rowStatus(r), c=(r.分类||{}).费用类别||'未分类', m=r.报销期间||(r.开票日期||'').slice(0,7);
    if(cat&&c!==cat)return false;
    if(st&&rs!==st)return false;
    if(mo&&m!==mo)return false;
    if(kw){var t=[r.id,r.发票号码,r.销方名称,r.项目名称,r.开票日期,(r.分类||{}).关联客户].join(' ');if(t.indexOf(kw)<0)return false;}
    return true;
  });
  var html='';
  rows.forEach(function(r){
    var c=r.分类||{}, rs=rowStatus(r), iss=issues(r);
    html+='<tr id="tr-'+esc(r.id)+'">'
      +'<td style="white-space:nowrap">'+esc(r.id||'')+'</td>'
      +'<td class="dt">'+esc(r.开票日期||'—')+'</td>'
      +'<td class="ell" title="'+esc(r.销方名称||'')+'">'+esc(r.销方名称||'—')+'</td>'
      +'<td class="ell" title="'+esc(r.项目名称||'')+'">'+esc(r.项目名称||'—')+'</td>'
      +'<td style="text-align:right">'+money(r.价税合计)+'</td>'
      +'<td><select class="sel" data-id="'+esc(r.id)+'" data-f="费用类别"'+(LOCK?' disabled':'')+'>'
        +CATS.map(function(x){return '<option'+(x===c.费用类别?' selected':'')+'>'+x+'</option>';}).join('')+'</select></td>'
      +'<td><input class="inp" data-id="'+esc(r.id)+'" data-f="关联客户" value="'+esc(c.关联客户||'')+'" placeholder="（点此填写）"'+(LOCK?' disabled':'')+'></td>'
      +'<td><input class="inp" data-id="'+esc(r.id)+'" data-f="陪同人数" style="width:64px" value="'+esc(c.陪同人数||'')+'"'+(LOCK?' disabled':'')+'></td>'
      +'<td><input class="inp" data-id="'+esc(r.id)+'" data-f="备注" value="'+esc(c.备注||'')+'" placeholder="（点此填写）"'+(LOCK?' disabled':'')+'></td>'
      +'<td><span class="badge b-'+rs+'">'+rs+'</span></td>'
      +'<td>'+ticketCtl(r)+supCtl(r)+'</td>'
      +'<td class="issues">'+esc(iss.slice(0,3).join('；'))
        +_warnNotes(r)+'</td>'
      +'</tr>';
  });
  $('#rows').innerHTML=html||'<tr><td colspan="12" style="text-align:center;color:#8a94a6;padding:26px">没有符合条件的票据</td></tr>';
  $('#cnt').textContent=rows.length+' / '+state.发票.length+' 张';
  var sum=rows.reduce(function(a,b){return a+(Number(b.价税合计)||0);},0);
  $('#sum').textContent=money(sum);
}

function summary(){
  var byCat={},byMo={};
  state.发票.forEach(function(r){
    var c=(r.分类||{}).费用类别||'未分类';
    byCat[c]=(byCat[c]||0)+(Number(r.价税合计)||0);
    var m=r.报销期间||(r.开票日期||'').slice(0,7)||'未知';
    byMo[m]=(byMo[m]||0)+(Number(r.价税合计)||0);
  });
  function tbl(o,t){
    var ks=Object.keys(o).sort(function(a,b){return o[b]-o[a];});
    var h='<table><tr><th>'+t+'</th><th style="text-align:right">金额（元）</th></tr>';
    ks.forEach(function(k){h+='<tr><td>'+esc(k)+'</td><td style="text-align:right">'+money(o[k])+'</td></tr>';});
    return h+'</table>';
  }
  $('#sumCat').innerHTML=tbl(byCat,'费用类别');
  $('#sumMo').innerHTML=tbl(byMo,'月份');
}

function kpis(){
  var s=state.汇总||{}, st=(s.按状态)||{};
  $('#k1').textContent=(s.票据数||0)+((s.附件数)?(' +'+s.附件数+'附件'):'');
  $('#k2').textContent=money(s.合计||0);
  $('#k3').textContent=st['待补']||0;
  $('#k4').textContent=st['异常']||0;
  $('#k5').textContent=st['重复']||0;
  $('#k6').textContent=st['通过']||0;
}

function supSel(r,f,opts,ph){
  var s=r.补录||{}, v=s[f]||'';
  return '<select class="sel" data-id="'+esc(r.id)+'" data-g="补录" data-f="'+f+'" style="margin-bottom:3px">'
    +'<option value="">'+ph+'</option>'
    +opts.map(function(o){return '<option'+(o===v?' selected':'')+'>'+o+'</option>';}).join('')
    +'</select>';
}
function supNum(r,f,ph,w){
  var s=r.补录||{};
  return '<input class="inp" style="width:'+(w||56)+'px" data-id="'+esc(r.id)+'" data-g="补录" data-f="'+f+'" value="'+esc(s[f]||'')+'" placeholder="'+ph+'">';
}
function supCtl(r){
  var c=(r.分类||{}).费用类别||'', src=(r.销方名称||'')+(r.项目名称||''), h='';
  if(c==='差旅-住宿'){
    h=supSel(r,'城市档位',['超一类城市','一类城市','二类城市'],'城市档位')+supNum(r,'间夜数','间夜')
     +supSel(r,'住宿清单',['已附','未附'],'住宿清单');
  } else if(c.indexOf('业务招待')===0){
    h=supNum(r,'就餐人数','人数')+supSel(r,'事前申请',['已关联','未关联'],'招待申请');
    if(/(航空|机票|网约车|滴滴|出行)/.test(src)) h+=supSel(r,'行程单',['已附','未附'],'行程单');
  } else if(c==='私车公用'){
    h=supNum(r,'公里数','公里')+supSel(r,'能源类型',['油车','电车'],'油/电')
     +supSel(r,'行驶记录明细表',['已附','未附'],'行驶记录')+supSel(r,'事前申请',['已关联','未关联'],'外出申请');
  } else if(c.indexOf('差旅')===0){
    h=supNum(r,'天数','天数')+supSel(r,'事前申请',['已关联','未关联'],'申请单');
    if(/(航空|机票|网约车|滴滴|出行)/.test(src)) h+=supSel(r,'行程单',['已附','未附'],'行程单');
  } else {
    h='<span class="notes">—</span>';
  }
  return h;
}
function _warnNotes(r){
  var ns=((r.校验||{}).提示)||[];
  var w=ns.filter(function(x){return x.indexOf('推定')>-1||x.indexOf('存疑')>-1||x.indexOf('核对')>-1;});
  return w.length?'<div style="color:#a8641a;font-size:11.4px">⚠ '+esc(w[0])+'</div>':'';
}
function ticketCtl(r){
  var need=[];
  if(!r.发票号码) need.push('发票号码');
  if(!r.开票日期) need.push('开票日期');
  if(!r.价税合计) need.push('价税合计');
  var need2=function(v){return !v || !/[\u4e00-\u9fa5]{2,}/.test(v+'');};
  if(need2(r.买方名称)) need.push('买方名称');
  if(!/^[0-9A-Z]{18}$/.test((r.买方税号||'')+'')) need.push('买方税号');
  if(need2(r.销方名称)) need.push('销方名称');
  if(!need.length) return '';
  var t=r.票面||{};
  return '<div style="font-size:11px;color:#c0392b;margin-bottom:2px">票面缺字段（照票手工补）</div>'
    + need.map(function(f){
        return '<input class="inp" style="width:100%;margin-bottom:2px" placeholder="'+f
          +'" data-id="'+esc(r.id)+'" data-g="票面" data-f="'+f+'" value="'+esc(t[f]||'')+'">';
      }).join('');
}
function jumpTo(id){
  ['fCat','fSt','fMo'].forEach(function(k){var e=document.getElementById(k);if(e)e.value='';});
  var kw=document.getElementById('fKw');if(kw)kw.value='';
  renderRows();
  var tr=document.getElementById('tr-'+id);
  if(!tr){toast('未找到该票据');return;}
  tr.scrollIntoView({block:'center'});
  tr.classList.add('hit');setTimeout(function(){tr.classList.remove('hit');},2400);
}
function download(name,mime,content){
  var b=new Blob([content],{type:mime+';charset=utf-8'});
  var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
  document.body.appendChild(a);a.click();setTimeout(function(){a.remove();},400);
}
function exportJson(){download('报销台账_'+new Date().toISOString().slice(0,10)+'.json','application/json',JSON.stringify(state,null,1));toast('✅ 已导出 JSON');}
function exportCsv(){
  var head=['id','开票日期','销方名称','项目名称','价税合计','费用类别','关联客户','陪同人数','备注','状态','问题'];
  var lines=[head.join(',')];
  state.发票.forEach(function(r){
    var c=r.分类||{};
    var row=[r.id,r.开票日期,r.销方名称,r.项目名称,r.价税合计,c.费用类别,c.关联客户,c.陪同人数,c.备注,rowStatus(r),issues(r).join(' / ')];
    lines.push(row.map(function(x){return '"'+String(x==null?'':x).replace(/"/g,'""')+'"';}).join(','));
  });
  download('报销明细_'+new Date().toISOString().slice(0,10)+'.csv','text/csv','\\ufeff'+lines.join('\\n'));
  toast('✅ 已导出 CSV');
}
function exportHtml(){
  saveLocal();
  var clone=document.documentElement.cloneNode(true);
  var cur=state;
  clone.querySelector('#appdata').textContent=JSON.stringify(cur);
  var d=new Date();
  download('报销助手_'+d.toISOString().slice(0,10)+'.html','text/html','<!DOCTYPE html>'+clone.outerHTML);
  toast('✅ 已导出（含当前数据）');
}
function lockToggle(btn){
  LOCK=!LOCK;btn.textContent=LOCK?'🔓 解锁编辑':'🔒 锁定';
  document.querySelectorAll('.sel,.inp').forEach(function(e){e.disabled=LOCK;});
  toast(LOCK?'已锁定（防误改）':'已解锁');
}
function resetAll(){
  if(!confirm('确定清空本机保存的所有手工改动？\\n（不会删除台账数据，可重新生成页面）'))return;
  try{localStorage.removeItem(STORE_KEY);}catch(e){}
  location.reload();
}
document.addEventListener('change',function(e){
  var el=e.target;
  if(el&&el.dataset&&el.dataset.id&&el.dataset.f){
    var r=state.发票.filter(function(x){return x.id===el.dataset.id;})[0];
    if(r){var g=el.dataset.g||'分类';r[g]=r[g]||{};r[g][el.dataset.f]=el.value;saveLocal();summary();renderRows();}
  }
});
document.addEventListener('input',function(e){
  var el=e.target;
  if(el&&el.dataset&&el.dataset.id&&el.dataset.f){
    var r=state.发票.filter(function(x){return x.id===el.dataset.id;})[0];
    if(r){var g=el.dataset.g||'分类';r[g]=r[g]||{};r[g][el.dataset.f]=el.value;clearTimeout(window.__st);window.__st=setTimeout(saveLocal,800);}
  }
});
['fCat','fSt','fMo','fKw'].forEach(function(id){
  var el=document.getElementById(id);
  if(el){el.addEventListener('change',renderRows);el.addEventListener('input',renderRows);}
});
mergeLocal();kpis();summary();renderRows();
"""


def build():
    led = core.load_ledger()
    rules = core.load_rules()
    cats = rules.get('费用类别') or ['其他']
    unconf = unconfigured(rules)
    data_json = json.dumps(led, ensure_ascii=False, indent=1)
    today = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')

    ORDER = ['异常', '重复', '待核', '待补']
    groups = {k: [] for k in ORDER}
    for r in led['发票']:
        st = (r.get('校验') or {}).get('状态')
        if st in groups:
            for p in ((r.get('校验') or {}).get('问题') or ['待处理']):
                groups[st].append((r.get('id'), p, r.get('价税合计')))
    cnt_problem = len({i for k in groups for i, _, _ in groups[k]})
    todo_html = ''
    if cnt_problem:
        def _box(cls, title, items):
            if not items:
                return ''
            ids = {i for i, _, _ in items}
            lis = ''.join('<div>• <span class="jump" onclick="jumpTo(\'%s\')">%s</span>：%s</div>'
                          % (esc0(i), esc0(i), esc0(p)) for i, p, _ in items)
            return '<div class="%s"><b>%s（%d 张票 / %d 条问题）</b>%s</div>' % (cls, title, len(ids), len(items), lis)
        todo_html = ('<h2>需处理清单 <span style="font-size:12px;color:#8a94a6;font-weight:400">'
                     '（共 %d 张票，点票号定位到明细行）</span></h2>'
                     % cnt_problem
                     + _box('bad-box', '🔴 异常 · 必须处理', groups['异常'])
                     + _box('bad-box', '🟣 重复 · 确认是否重报', groups['重复'])
                     + _box('note', '🔵 待核 · 人工确认', groups['待核'])
                     + _box('warn', '🟡 待补 · 补齐后重跑校验', groups['待补']))
    unconf_html = ''
    if unconf:
        unconf_html = ('<div class="warn"><b>⚙️ 规则未配置（以下判定当前不做，不是通过）</b>' +
                       ''.join('<div>• %s</div>' % esc0(u) for u in unconf) +
                       '<div style="margin-top:8px;padding-top:8px;border-top:1px dashed #e0c88a">'
                       '<b>怎么补齐：</b>编辑 <code>报销助手/数据/规则库.json</code> 对应字段（把 [待填] 换成真实值）→ '
                       '然后重跑：<code>/usr/bin/python3 脚本/invoice_check.py &amp;&amp; /usr/bin/python3 脚本/build_page.py</code>'
                       '</div></div>')

    mo_opts = sorted({(r.get('报销期间') or (r.get('开票日期') or '')[:7]) for r in led['发票'] if r.get('报销期间') or r.get('开票日期')})

    html = """<!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="page">
  <div class="topbar">__LOGO__
    <div class="t">报销助手<small>EXPENSE ASSISTANT · 提交方工作台</small></div>
    <div class="rt">更新于 __TODAY__<br>数据源：数据/报销台账.json</div>
  </div>
  <div class="wrap">
    <div class="kpis">
      <div class="kpi"><div class="n" id="k1">0</div><div class="l">票据数（+配套材料）</div></div>
      <div class="kpi"><div class="n" id="k2">0.00</div><div class="l">合计金额（元）</div></div>
      <div class="kpi warn"><div class="n" id="k3">0</div><div class="l">待补（缺信息）</div></div>
      <div class="kpi bad"><div class="n" id="k4">0</div><div class="l">异常（不合规）</div></div>
      <div class="kpi"><div class="n" id="k5">0</div><div class="l">重复</div></div>
      <div class="kpi ok" title="仅表示：结构校验 + 已配置规则全部通过；未配置的规则项未参与判定"><div class="n" id="k6">0</div><div class="l">通过（限已配置规则）</div></div>
    </div>

    <details open><summary>📖 怎么用（三步）</summary>
      <div style="font-size:12.8px;color:#4a6178;line-height:1.9">
        ① <b>把发票拖进</b> <code>报销助手/数据/收件箱/</code>（支持 数电票 XML、电子发票 PDF、拍照图片）<br>
        ② 运行 <code>/usr/bin/python3 报销助手/脚本/invoice_parse.py</code> → 自动解析 + 校验 + 入台账<br>
        &nbsp;&nbsp;&nbsp;<b>补录列</b>：按费用类别补填判定所需信息（住宿→城市档位+间夜数；招待→就餐人数；私车公用→公里数+油/电；差旅→天数+是否关联申请单），<b>填了才能判超标</b><br>
        ③ 打开本页复核：改分类/挂客户/填陪同人数 → 点「💾 保存」；<br>
        &nbsp;&nbsp;&nbsp;需要交给财务时点「📄 导出 HTML」（含当前数据）或「📊 导出 CSV」<br>
        <span style="color:#8a94a6">※ 本页所有改动只存在你本机浏览器；导出 HTML 才会把数据带走。</span>
      </div>
    </details>

    __UNCONF__
    __TODO__

    <h2>票据明细 <span id="cnt" style="font-size:12px;color:#8a94a6;font-weight:400"></span>
      <span style="font-size:12px;color:#8a94a6;font-weight:400;margin-left:8px">筛选后合计 <b id="sum">0.00</b> 元</span></h2>
    <div class="bar">
      <select id="fCat"><option value="">全部类别</option>__CATOPTS__</select>
      <select id="fSt"><option value="">全部状态</option><option>通过</option><option>待补</option><option>异常</option><option>重复</option><option>待核</option><option>附件</option></select>
      <select id="fMo"><option value="">全部月份</option>__MOOPTS__</select>
      <input id="fKw" placeholder="搜 号码/销方/项目/客户…" style="min-width:220px">
      <div class="tb" style="margin-left:auto">
        <button onclick="renderRows()">🔍 筛选</button>
        <button class="pri" onclick="saveLocal()">💾 保存</button>
      </div>
    </div>
    <table>
      <thead><tr>
        <th style="width:118px">编号</th><th style="width:102px">开票日期</th><th>销方</th><th>项目</th>
        <th style="width:82px;text-align:right">价税合计</th><th style="width:112px">费用类别 ✏️</th>
        <th style="width:126px">关联客户/项目 ✏️</th><th style="width:62px">陪同人数 ✏️</th><th style="width:126px">备注 ✏️</th>
        <th style="width:66px">状态</th><th style="width:200px">补录（票面缺字段 + 按类别需要）✏️</th><th>问题 / 提示</th>
      </tr></thead>
      <tbody id="rows"></tbody>
    </table>

    <div class="two">
      <div><h2>按费用类别汇总</h2><div id="sumCat"></div></div>
      <div><h2>按月份汇总</h2><div id="sumMo"></div></div>
    </div>

    <div class="foot">
      口径依据：<b>CA-01-03-A3《付款、费用报销管理制度》</b>§5.3.4 费用标准 + 制度 PPT「报销费用标准总览」+《付款&费用报销单据粘贴要求》；<br>
      住宿 超一类 350 / 一类 300 / 二类 220 元/人/晚 ｜ 出差补助 80 元/天（不满一天减半）｜ 私车公用 油车 1.5、电车 0.8 元/公里 ｜ 招待 餐厅 120、外卖 40 元/人/餐；<br>
      金额取发票「价税合计」；分类由票面项目/销方初判，可手工修改；<br>
      本页为自包含页面，数据内联，可离线打开；导出 HTML 会带上当前所有数据与改动。
    </div>
  </div>
</div>
<div class="msg" id="msg"></div>
<div class="fixedtb">
  <button onclick="lockToggle(this)">🔒 锁定</button>
  <button onclick="exportHtml()">📄 导出 HTML</button>
  <button onclick="exportCsv()">📊 导出 CSV</button>
  <button onclick="exportJson()">🗂 导出 JSON</button>
  <button onclick="window.print()">🖨 打印</button>
  <button onclick="resetAll()">↺ 重置</button>
  <button class="pri" onclick="saveLocal()">💾 保存</button>
</div>
<script id="appdata" type="application/json">__DATA__</script>
<script>var state=JSON.parse(document.getElementById('appdata').textContent);__JS__</script>
</body></html>"""

    html = (html.replace('__CSS__', CSS)
                .replace('__LOGO__', ('<img src="%s" alt="BIORI">' % logo_b64()) if logo_b64() else '')
                .replace('__TODAY__', today)
                .replace('__UNCONF__', unconf_html)
                .replace('__TODO__', todo_html)
                .replace('__CATOPTS__', ''.join('<option>%s</option>' % esc0(c) for c in cats))
                .replace('__MOOPTS__', ''.join('<option>%s</option>' % esc0(m) for m in mo_opts))
                .replace('__DATA__', data_json)
                .replace('__JS__', JS.replace('__CATS__', json.dumps(cats, ensure_ascii=False))))
    out = os.path.join(core.TOOLS, '报销助手.html')
    open(out, 'w', encoding='utf-8').write(html)
    print('✅ %s  %.1f KB' % (out, os.path.getsize(out) / 1024))
    print('   票据 %d 张 ｜ 需处理 %d 张 ｜ 规则未配置 %d 项 ｜ 宏残留：%s'
          % (len(led['发票']), cnt_problem, len(unconf),
             [x for x in ['__CSS__', '__JS__', '__DATA__', '__CATS__', '__LOGO__', '__TODAY__',
                          '__UNCONF__', '__TODO__', '__CATOPTS__', '__MOOPTS__'] if x in html]))
    return out


def esc0(s):
    return str(s if s is not None else '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')


if __name__ == '__main__':
    build()
