#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""报销助手 · 票据解析（收件箱 → 结构化发票记录）

支持格式：
  .xml  数电票 XML（最稳：字段直接读，不靠识别）
  .pdf  电子发票 PDF（读文本层 + 正则；数电票 PDF、铁路/民航电子客票）
  .ofd  版式文件（暂提示转 PDF/XML，见 README）
  图片  拍照/截图 → 走 macOS Vision OCR（复用 handwriting-ocr 技能脚本）

用法：
  /usr/bin/python3 脚本/invoice_parse.py                # 处理 数据/收件箱/
  /usr/bin/python3 脚本/invoice_parse.py 某文件.xml      # 处理指定文件
  /usr/bin/python3 脚本/invoice_parse.py --dry          # 只解析不入库（看识别效果）

设计铁律：
  1) 识别不到就留空 + 记入『缺失字段』，绝不猜数字（金额/号码猜错比空着危险）
  2) 同一份文件（hash）或同一张发票（号码+开票日期）重复出现 → 标记重复，不重复入库
  3) 原始文件一律归档到 收件箱/已处理/，台账只存相对路径，保证可追溯
"""
import os
import re
import sys
import glob
import shutil
import subprocess

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

# ── 字段别名表：按「元素本地名归一化」匹配，兼容各版本 XML 命名 ──────────
ALIASES = {
    '发票号码': ['invoicenumber', 'invoiceno', 'fphm', '发票号码'],
    '发票代码': ['invoicecode', 'fpdm', '发票代码'],
    '开票日期': ['issuetime', 'invoicedate', 'issuedate', 'kprq', '开票日期', '开票时间'],
    '买方名称': ['buyername', 'buyer', '购买方名称', '购方名称', '付款方名称'],
    '买方税号': ['buyertaxid', 'buyertaxno', 'buyertaxpayerid', '购买方纳税人识别号', '购方税号', '纳税人识别号'],
    '销方名称': ['sellername', 'seller', '销售方名称', '销方名称'],
    '销方税号': ['sellertaxid', 'sellertaxno', 'sellertaxpayerid', '销售方纳税人识别号'],
    '价税合计': ['totaltaxincludedamount', 'totalamount', 'amountwithtax', '价税合计', '价税合计小写', '合计金额'],
    '金额': ['totalamwithouttax', 'amountwithouttax', 'amount', '合计金额不含税', '金额'],
    '税额': ['totaltaxam', 'taxamount', 'totalTax', '合计税额'],
    '项目名称': ['itemname', 'goodsname', '货物或应税劳务名称', '项目名称', '服务名称'],
    '发票类型': ['invoicetype', '发票种类', '发票类型'],
    '税率': ['taxrate', '税率'],
    '备注': ['remark', '备注'],
}


def norm(s):
    return re.sub(r'[^0-9a-z\u4e00-\u9fa5]', '', str(s or '').lower())


NORM_ALIASES = {f: [norm(a) for a in a_list] for f, a_list in ALIASES.items()}


OCR_BIN = core.OCR_BIN
OCR_SWIFT = os.path.expanduser(
    '~/.hermes/skills/macos/macos-vision-image-processing/scripts/ocr_pdf.swift')


def ensure_ocr_bin():
    """编译 macOS Vision 的 PDF OCR（一次编译，后续直接调用，约 1s/页）"""
    if os.path.exists(OCR_BIN):
        return OCR_BIN
    if not os.path.exists(OCR_SWIFT):
        return None
    try:
        subprocess.run(['swiftc', '-O', '-o', OCR_BIN, OCR_SWIFT],
                       capture_output=True, text=True, timeout=240)
    except Exception:
        pass
    return OCR_BIN if os.path.exists(OCR_BIN) else None


def ocr_pdf_text(path):
    """逐页 OCR（读到的是视觉顺序：标签紧邻值）——数电票文本层常是『标签在前值在后』的乱序，
    OCR 用于补齐 买方/销方名称、价税合计等字段。失败返回空串。"""
    exe = ensure_ocr_bin()
    if not exe:
        return ''
    try:
        r = subprocess.run([exe, path], capture_output=True, text=True, timeout=180)
        return (r.stdout or '').strip()
    except Exception:
        return ''


def _num(s):
    """金额字符串 → float；识别不到返回 None（绝不返回 0）"""
    if s is None:
        return None
    t = re.sub(r'[^\d.\-]', '', str(s))
    if t in ('', '.', '-'):
        return None
    try:
        return round(float(t), 2)
    except ValueError:
        return None


def _date(s):
    if not s:
        return None
    t = str(s).strip()
    m = re.search(r'(20\d{2})[-/年.]?(\d{1,2})[-/月.]?(\d{1,2})', t)
    if m:
        return '%04d-%02d-%02d' % (int(m.group(1)), int(m.group(2)), int(m.group(3)))
    m = re.match(r'^(\d{4})(\d{2})(\d{2})', t)
    if m:
        return '%s-%s-%s' % (m.group(1), m.group(2), m.group(3))
    return None


# ── XML（数电票）────────────────────────────────────────────
def parse_xml(path):
    from lxml import etree
    root = etree.parse(path).getroot()
    flat = {}
    for el in root.iter():
        if not isinstance(el.tag, str):
            continue
        ln = etree.QName(el).localname
        txt = (el.text or '').strip()
        if txt:
            flat.setdefault(norm(ln), []).append(txt)
    out, missing = {}, []
    for field, aliases in NORM_ALIASES.items():
        val = best_match(flat, aliases)
        out[field] = val
        if not val and field in ('发票号码', '开票日期', '销方名称', '价税合计'):
            missing.append(field)
    return out, missing


def best_match(flat, aliases):
    """按『评分』选字段，而不是首个模糊命中。

    踩过的坑：XML 里同时存在 <Amount>(不含税金额) 和 <TotalTax-includedAmount>(价税合计)，
    若用 `a.endswith(k)` 这种松匹配，长别名 totaltaxincludedamount 会被短键 amount 抢先命中，
    导致「价税合计」读成不含税金额 → 全部误报「金额不自洽」。
    评分：精确 > 键以别名结尾 > 别名以键结尾（弱）> 互相包含；同级取更长者。
    """
    best, best_score = None, 0
    for a in aliases:
        na = a
        for k, v in flat.items():
            sc = 0
            if k == na:
                sc = 1000 + len(na)
            elif k.endswith(na):
                sc = 600 + len(na)
            elif na.endswith(k):
                sc = 300 + len(k)
            elif na in k or k in na:
                sc = 100 + min(len(na), len(k))
            if sc > best_score:
                best_score, best = sc, v[0]
    return best


# ── PDF ────────────────────────────────────────────────────
PDF_PATTERNS = {
    '发票号码': [r'发票号码\s*[：:]?\s*([0-9]{15,25})', r'\b([0-9]{20})\b'],
    '发票代码': [r'发票代码\s*[：:]?\s*([0-9]{10,12})'],
    '开票日期': [r'开票日期\s*[：:]?\s*(20\d{2}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日)',
                 r'开票日期\s*[：:]?\s*(20\d{2}[-/]\d{1,2}[-/]\d{1,2})',
                 r'(20\d{2}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日)',
                 r'开票日期\s*[：:]?\s*((?:20\d{2}\s*年[^\n]{0,14}日))'],
    '买方名称': [r'购\s*买?\s*方[\s\S]{0,26}?名\s*称\s*[：:]\s*([^\n，,]{2,44})',
                 r'购\s*买?\s*方\s*名\s*称\s*[：:]\s*([^\n]{2,44})'],
    '买方税号': [r'购\s*买?\s*方[\s\S]{0,100}?([0-9A-Z]{18})(?![0-9A-Z])'],
    '销方名称': [r'销\s*售\s*方[\s\S]{0,26}?名\s*称\s*[：:]\s*([^\n，,]{2,44})',
                 r'销\s*售\s*方\s*名\s*称\s*[：:]\s*([^\n]{2,44})'],
    '销方税号': [r'销\s*售\s*方[\s\S]{0,100}?([0-9A-Z]{18})(?![0-9A-Z])'],
    '价税合计': [r'价\s*税\s*合\s*计[\s\S]{0,40}?[（(]\s*小写\s*[)）]\s*[¥￥]?\s*([\d,]+\.\d{2})',
                 r'[（(]\s*小写\s*[)）]\s*[¥￥]?\s*([\d,]+\.\d{2})',
                 r'价\s*税\s*合\s*计[\s\S]{0,40}?[¥￥]\s*([\d,]+\.\d{2})'],
    '金额': [r'合\s*计[\s\S]{0,18}?[¥￥]?\s*([\d,]+\.\d{2})'],
    '税额': [r'合\s*计[\s\S]{0,44}?[¥￥]?\s*[\d,]+\.\d{2}[^\d]{0,14}[¥￥]?\s*([\d,]+\.\d{2})'],
    '发票类型': [r'(电子发票\s*[（(][^）)]{2,16}[）)])', r'(增值税[电子]*[普通专用]*发票)'],
}


def _pdf_text(path):
    txt = ''
    try:
        import fitz
        doc = fitz.open(path)
        txt = '\n'.join(p.get_text() for p in doc)
        doc.close()
    except Exception:
        txt = ''
    if len(txt.strip()) < 20:
        try:
            import pdfplumber
            with pdfplumber.open(path) as pdf:
                txt = '\n'.join((p.extract_text() or '') for p in pdf.pages)
        except Exception:
            pass
    return txt


def parse_pdf(path):
    """文本层 + OCR（视觉顺序）双通道；金额兜底取票面最大值并标『推定』。"""
    layer = _pdf_text(path)
    ocr = ocr_pdf_text(path)
    full = (ocr + '\n' + layer) if ocr else layer
    out, missing = {}, []
    for field, pats in PDF_PATTERNS.items():
        val = None
        for p in pats:
            try:
                m = re.search(p, full, re.S)
            except re.error:
                m = None
            if m:
                val = (m.group(1) if m.groups() else m.group(0)).strip()
                break
        out[field] = val
        if not val and field in ('发票号码', '开票日期', '销方名称', '价税合计'):
            missing.append(field)
    # 数电票「值批次」兜底：文本层/OCR 里标签与值分离时，值区顺序为
    # 号码 → 日期 → 买方名称 → 买方税号 → 销方名称 → 销方税号（实测多种版式一致）
    if not out.get('销方名称'):
        # 护栏：① 税号必须恰为 18 位（右侧加边界断言，否则 20 位发票号码会被截成 18 位误判为税号）
        #       ② 名称必须含中文（公司名/人名），排除把号码或标签当名称
        _CJK = '[\u4e00-\u9fa5]'
        m = re.search(r'\b([0-9]{20})\b\s+(20\d{2}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日)'
                      r'\s+(' + _CJK + r'[^\n]{1,39}?)\s+([0-9A-Z]{18})(?![0-9A-Z])'
                      r'\s+(' + _CJK + r'[^\n]{1,39}?)\s+([0-9A-Z]{18})(?![0-9A-Z])', full, re.S)
        if m:
            out['买方名称'] = out.get('买方名称') or m.group(3)
            out['买方税号'] = out.get('买方税号') or m.group(4)
            out['销方名称'] = m.group(5)
            out['销方税号'] = out.get('销方税号') or m.group(6)
            missing = [x for x in missing if x != '销方名称']
    # 清洗无效值（如只抓到「名称：」）
    import re as _re
    for k in ('买方名称', '销方名称'):
        v = (out.get(k) or '').strip()
        bad = (len(v) < 3
               or v.replace('：', '').replace(':', '').strip() in ('名称', '销方', '购方')
               or _re.fullmatch(r'[0-9A-Z]{8,}', v)
               # 🔴 黑名单必须含「下载次数」：数电票 PDF 名称标签后常紧跟「下载次数：1」，
               #    实测曾把销方名称解析成「下载次数：1」，导致酒店票被归入错误费用列
               or any(t in v for t in ('纳税人识别号', '统一社会信用代码', '号码', '开票日期',
                                       '下载次数', '开票人', '名称：'))
               or not _re.search(r'[\u4e00-\u9fa5]', v))
        if bad:
            out[k] = None
    # 销方名称兜底：部分版式（网约车/高速通行费）文中无「销售方」字样，但有公司名
    # → 取公司名候选并标『推定』，页面可改（绝不当成确定值）
    if not out.get('销方名称'):
        cands = re.findall(r'[\u4e00-\u9fa5]{2,18}(?:股份有限公司|有限责任公司|有限公司|公司|管理中心|收费站|供电局|银行|旅行社|酒店|宾馆|集团|中心)',
                           full)
        buyer = (out.get('买方名称') or '')
        cands = [x for x in cands if x not in buyer and '宝锐' not in x and len(x) >= 6]
        if cands:
            out['销方名称'] = cands[-1]
            out['_销方推定'] = True
    note = ''
    if not out.get('价税合计'):
        cands = [float(x.replace(',', '')) for x in re.findall(r'[¥￥]\s*([\d,]+\.\d{2})', full)]
        if not cands:
            cands = [float(x.replace(',', '')) for x in re.findall(r'([\d,]+\.\d{2})', full)]
        cands = [c for c in cands if 0 < c < 1000000]
        if cands:
            out['价税合计'] = '%.2f' % max(cands)
            note = '价税合计为推定值（票面最大金额），请核对'
            missing = [x for x in missing if x != '价税合计']
    out['_文本长度'] = len(layer)
    out['_OCR长度'] = len(ocr)
    out['_备注'] = note
    # 票面文本核验：抬头/税号的最强兜底（与版式无关）
    names, tax = _company_markers()
    flat = re.sub(r'\s+', '', full)
    out['_票面含公司名称'] = any(n.replace(' ', '') in flat for n in names)
    out['_票面含公司税号'] = bool(tax) and tax.replace(' ', '') in flat
    return out, missing


# ── 图片（走 macOS Vision OCR）─────────────────────────────
def find_ocr_script():
    pats = [
        os.path.expanduser('~/.hermes/skills/*/handwriting-ocr/scripts/ocr_handwriting.py'),
        os.path.expanduser('~/.hermes/skills/*/*/handwriting-ocr/scripts/ocr_handwriting.py'),
        os.path.expanduser('~/Desktop/Hermes输出-工作类/脚本/ocr_handwriting.py'),
    ]
    for p in pats:
        hit = glob.glob(p)
        if hit:
            return hit[0]
    return None


def parse_image(path):
    script = find_ocr_script()
    if not script:
        return {'_需OCR': True}, ['发票号码', '开票日期', '销方名称', '价税合计'], '未找到 OCR 脚本'
    try:
        r = subprocess.run(['/usr/bin/python3', script, path], capture_output=True, text=True, timeout=120)
        txt = r.stdout or ''
    except Exception as e:
        return {'_需OCR': True}, list(PDF_PATTERNS.keys()), 'OCR 失败：%s' % str(e)[:60]
    # 复用 PDF 正则
    out, missing = {}, []
    for field, pats in PDF_PATTERNS.items():
        val = None
        for p in pats:
            m = re.search(p, txt, re.S)
            if m:
                val = (m.group(1) if m.groups() else m.group(0)).strip()
                break
        out[field] = val
        if not val and field in ('发票号码', '开票日期', '销方名称', '价税合计'):
            missing.append(field)
    out['_OCR文本'] = txt[:2000]
    return out, missing, None


# ── 统一入口 ───────────────────────────────────────────────
def _company_markers():
    """从规则库取公司名称/税号，用于票面文本核验（字段结构化提取不可靠时的兜底）"""
    try:
        ci = (core.load_rules() or {}).get('公司开票信息') or {}
        names = [ci.get('名称')] + list(ci.get('抬头关键词') or [])
        return [x for x in names if x], (ci.get('税号') or '')
    except Exception:
        return ['珠海宝锐生物科技有限公司', '宝锐生物'], '91440400592109797M'


def parse_file(path):
    # 配套材料不是发票：结算单/对账单/行程报销单/入住确认，一律跳过（曾把一嗨结算单 1113.99 当发票入账）
    import re as _re
    if _re.search(r'(结算单|对账单|行程报销单|入住确认|费用明细)', os.path.basename(path)):
        return None
    ext = os.path.splitext(path)[1].lower()
    err = None
    if ext == '.xml':
        raw, missing = parse_xml(path)
        fmt = 'xml'
    elif ext == '.pdf':
        raw, missing = parse_pdf(path)
        fmt = 'pdf'
    elif ext in ('.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tif', '.tiff', '.heic'):
        raw, missing, err = parse_image(path)
        fmt = 'image'
    elif ext == '.ofd':
        raw, missing = {}, ['发票号码', '开票日期', '销方名称', '价税合计']
        err = 'OFD 版式文件：请在开票方/税务数字账户另存为 PDF 或 XML 后再拖入'
        fmt = 'ofd'
    else:
        return None
    rec = {
        '发票号码': (raw.get('发票号码') or '').strip() or None,
        '发票代码': (raw.get('发票代码') or '').strip() or None,
        '开票日期': _date(raw.get('开票日期')),
        '买方名称': (raw.get('买方名称') or '').strip() or None,
        '买方税号': (raw.get('买方税号') or '').strip() or None,
        '销方名称': (raw.get('销方名称') or '').strip() or None,
        '销方税号': (raw.get('销方税号') or '').strip() or None,
        '发票类型': (raw.get('发票类型') or '').strip() or None,
        '项目名称': (raw.get('项目名称') or '').strip() or None,
        '金额': _num(raw.get('金额')),
        '税额': _num(raw.get('税额')),
        '价税合计': _num(raw.get('价税合计')),
        '来源格式': fmt,
        '来源文件': os.path.basename(path),
        '文件hash': core.file_hash(path),
        '缺失字段': missing,
        '解析备注': err or raw.get('_备注'),
        '_销方推定': raw.get('_销方推定'),
        '_票面含公司名称': raw.get('_票面含公司名称'),
        '_票面含公司税号': raw.get('_票面含公司税号'),
    }
    return rec


def main(argv):
    dry = '--dry' in argv
    args = [a for a in argv if not a.startswith('--')]
    if args:
        paths = []
        for a in args:
            if os.path.isdir(a):
                paths += [os.path.join(a, f) for f in sorted(os.listdir(a))
                          if os.path.isfile(os.path.join(a, f)) and not f.startswith('.')]
            else:
                paths.append(a)
    else:
        paths = [os.path.join(core.INBOX, f) for f in sorted(os.listdir(core.INBOX))
                 if os.path.isfile(os.path.join(core.INBOX, f)) and not f.startswith('.')]

    if not paths:
        print('📭 收件箱为空：把发票文件（XML/PDF/图片）拖进 %s 再运行' % core.INBOX)
        return 0

    import invoice_check
    led = core.load_ledger()
    _srcp = os.path.join(core.DATA, '邮件来源.json')
    try:
        _src = (json.load(open(_srcp, encoding='utf-8')).get('文件') or {}) if os.path.exists(_srcp) else {}
    except Exception:
        _src = {}
    rules = core.load_rules()
    known_num = {(r.get('发票号码'), r.get('开票日期')) for r in led['发票'] if r.get('发票号码')}
    known_hash = {r.get('文件hash') for r in led['发票']}

    added, dup, failed = 0, 0, 0
    print('处理 %d 个文件：\n' % len(paths))
    for p in paths:
        rec = parse_file(p)
        if not rec:
            print('  ⚠️ 跳过（不支持格式）%s' % os.path.basename(p))
            failed += 1
            continue
        key = (rec['发票号码'], rec['开票日期'])
        if rec['文件hash'] in known_hash:
            print('  🔁 重复（同一文件已入库）%s' % rec['来源文件'])
            dup += 1
            continue
        if rec['发票号码'] and key in known_num:
            print('  🔁 重复（同号同日期已存在）%s 号=%s' % (rec['来源文件'], rec['发票号码']))
            dup += 1
            continue
        meta = _src.get(os.path.basename(p)) or {}
        if meta:
            rec['邮件'] = {'message_id': meta.get('message_id'), 'subject': meta.get('subject'),
                           'from': meta.get('from'), 'date': meta.get('date')}
        kind = meta.get('kind') or ''
        _fn = rec.get('来源文件') or ''
        # 文件名含「清单」且不含「发票」→ 明细清单（如 ETC 通行费清单），属配套材料而非发票
        if not kind and '清单' in _fn and '发票' not in _fn:
            kind = '费用清单'
        if kind or (not rec.get('发票号码') and _companion_like(_fn)):
            rec['类型'] = '附件'
            rec['附件类别'] = kind or _companion_like(rec['来源文件']) or '其他'
            rec['缺失字段'] = []
            rec['解析备注'] = '配套材料（非发票），已尝试自动关联到同封邮件/同号的发票'
        else:
            rec['类型'] = '发票'
        rec['id'] = core.next_id(led, 'ATT' if rec['类型'] == '附件' else 'INV')
        rec['分类'] = {'费用类别': '配套材料' if rec['类型'] == '附件' else guess_category(rec),
                       '关联客户': '', '关联项目': '', '备注': ''}
        rec['报销期间'] = (rec.get('开票日期') or core.today())[:7]
        rec['归属'] = {'报销单': '', '状态': '待归集'}
        rec['校验'] = {'状态': '附件', '问题': [], '提示': ['配套材料，随发票提交']} if rec['类型'] == '附件' \
            else invoice_check.check_record(rec, rules, led)
        rec['录入时间'] = core.now()
        led['发票'].append(rec)
        known_hash.add(rec['文件hash'])
        if rec['发票号码']:
            known_num.add(key)
        added += 1
        print('  ✅ %s → %s ｜ %s ｜ %s 元 ｜ %s'
              % (rec['来源文件'], rec['id'], rec['开票日期'] or '日期?',
                 ('%.2f' % rec['价税合计']) if rec['价税合计'] is not None else '金额?',
                 rec['校验']['状态'] + ('（%s）' % '、'.join(rec['校验']['问题']) if rec['校验']['问题'] else '')))
        if rec['缺失字段']:
            print('     ⚠️ 缺失字段（需人工补）：%s' % '、'.join(rec['缺失字段']))
        if rec['解析备注']:
            print('     ℹ️ %s' % rec['解析备注'])
        if not dry:
            dest = os.path.join(core.DONE, os.path.basename(p))
            if os.path.exists(dest):
                dest = dest.replace('.', '_%s.' % core.now().replace(':', '').replace(' ', '_'))
            shutil.move(p, dest)
            rec['来源文件归档'] = os.path.relpath(dest, core.BASE)

    if not dry:
        linked = relink(led)
        if linked:
            print('\n🔗 自动关联配套材料 %d 处' % linked)
        core.save_ledger(led)
    print('\n小结：新增 %d ｜ 重复 %d ｜ 失败 %d ｜ 台账现有 %d 张'
          % (added, dup, failed, len(led['发票'])))
    if dry:
        print('（--dry 模式：未写台账、未移动文件）')
    else:
        core.log('parse', '新增 %d 重复 %d 失败 %d' % (added, dup, failed))
    return 0


def _companion_like(name):
    low = str(name or '').lower()
    for kind, kws in [('住宿清单', ['结账单', '住宿清单', '入住清单', '消费明细', '水单']),
                      ('行程单', ['行程单', 'itinerary']),
                      ('对账单', ['对账单', '月结单']),
                      ('费用清单', ['清单', '明细表'])]:
        if any(k in low for k in kws):
            return kind
    return ''


def relink(led):
    """把配套材料（结账单/住宿清单/行程单）自动回填到对应发票的『补录』字段。
    两种匹配：① 同一封邮件；② 配套材料文件名里含发票号码（跨邮件，如航旅行程单）。"""
    atts = [r for r in led['发票'] if r.get('类型') == '附件']
    invs = [r for r in led['发票'] if r.get('类型') != '附件']
    n = 0
    for a in atts:
        kind = a.get('附件类别') or ''
        if kind not in ('住宿清单', '行程单', '对账单'):
            continue
        field = kind
        mid = (a.get('邮件') or {}).get('message_id') or ''
        fname = a.get('来源文件') or ''
        targets = []
        for inv in invs:
            same_mail = mid and (inv.get('邮件') or {}).get('message_id') == mid
            num = str(inv.get('发票号码') or '')
            in_name = num and len(num) >= 12 and num in fname
            if same_mail or in_name:
                targets.append(inv)
        for inv in targets:
            sup = inv.setdefault('补录', {})
            if sup.get(field) != '已附':
                sup[field] = '已附'
                n += 1
    return n


def guess_category(rec):
    """按 项目名称/销方/文件名 粗分类 —— 只做初判，页面可改。

    制度依据：私车公用的燃油费/路桥费/停车费凭票实报实销（需《行驶记录明细表》+ 外出申请）；
    打车（出租车/网约车）属目的地交通；铁路/航空属旅途交通。
    """
    t = '%s %s %s %s' % (rec.get('项目名称') or '', rec.get('销方名称') or '',
                         rec.get('发票类型') or '', rec.get('来源文件') or '')
    # 车牌号命中（如 粤CG622Z.zip / 粤CG622Z_通行费.pdf）→ 私车公用
    plate = re.compile(r'[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼]'
                       r'[A-Z][A-Z0-9]{4,6}')
    if plate.search(t):
        return '私车公用'
    rules = [
        ('差旅-住宿', ['住宿', '酒店', '宾馆', '客房', '旅店']),
        ('私车公用', ['通行费', '过路', '路桥', '停车', '加油', '汽油', '燃油', '成品油', 'ETC', '高速']),
        ('差旅-交通', ['铁路', '火车', '航空', '机票', '客票', '运输服务', '行程单', '旅客']),
        ('差旅-市内交通', ['出租车', '网约车', '滴滴', '曹操', '地铁', '公交', '客运', '阳光出行', '车费']),
        ('业务招待-餐厅', ['餐饮', '餐费', '宴会', '酒水']),
        ('业务招待-外卖', ['外卖', '美团', '饿了么']),
        ('快递费', ['快递', '物流', '邮费', '顺丰', '京东物流']),
        ('通讯费', ['电信', '通信', '话费', '移动通信', '联通', '中国联合网络']),
        ('办公用品', ['办公', '文具', '耗材']),
        ('会务费', ['会议', '会务', '场地']),
    ]
    for cat, kws in rules:
        if any(k in t for k in kws):
            return cat
    return '其他'


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
