报销单自动归集:按事由关键词分到一级科目,冲突的交人工

2026-10-06 · 喵窝资源铺

月底财务最烦的一步:把几十条「出差北京 高铁票来回」「客户聚餐 餐费」这种自由文本事由,归到差旅费 / 业务招待费 / 办公费这些一级科目里,再算每科小计。Excel 里筛半天,还容易归错——归错的科目在账上是真金白银的错。这篇给一个 80 行脚本:读报销单 Excel,按事由关键词自动归科目,输出分科目小计 + 合计 + 待人工确认清单。代码实跑验证,真实输出贴在下面。

输入输出长什么样

输入一张典型报销单(含纯空格事由、None、缺报销人的脏行),跑完:

=== 归集结果 ===
差旅费: 3 笔 小计 1662.00
    张三 | 出差北京 高铁票来回 | 736
    张三 | 酒店住宿2晚 | 880
    王五 | 打车费 出租车 | 46
业务招待费: 1 笔 小计 560.50
    李四 | 客户聚餐 餐费 | 560.5
办公费: 1 笔 小计 129.90
    李四 | 打印耗材 办公用品 | 129.9
it/云服务费: 1 笔 小计 4200.00
    赵六 | 购买服务器 云服务 | 4200
其他/待人工确认: 3 笔 小计 550.00
    钱七 | 会议礼品+快递费 | 200   ← 冲突项,见下文真坑

合计 7102.40
跳过脏行 1 行: 缺报销人的「办公用品采购 88」

10 行数据(含 3 类脏行)4 个科目全归对,合计与手工核对一致。

完整脚本

# -*- coding: utf-8 -*-
"""报销单按事由关键词归集到一级科目,输出分科目小计+待人工确认清单。"""
from openpyxl import load_workbook
import sys

# 关键词 -> 一级科目(按公司科目表改这里就行)
RULES = [
    ('差旅费', ['高铁', '火车', '机票', '酒店', '住宿', '出差', '打车', '出租车']),
    ('业务招待费', ['聚餐', '招待', '客户', '餐费', '宴请', '礼品']),
    ('办公费', ['办公用品', '打印', '耗材', '快递', '文件']),
    ('it/云服务费', ['服务器', '云服务', '域名', '云主机']),
]

def match(subject):
    """返回 (科目, 命中类别列表, 命中关键词)。多科目冲突时科目=None。"""
    if not subject:
        return None, [], []
    s = str(subject)
    cats = {}
    words = []
    for cat, kws in RULES:
        for kw in kws:
            if kw in s:
                cats.setdefault(cat, []).append(kw)
                words.append(kw)
    hit_cats = sorted(cats)
    if not hit_cats:
        return None, [], []
    if len(hit_cats) > 1:
        return None, hit_cats, words   # 冲突 -> 不猜,交人工
    return hit_cats[0], hit_cats, words

def run(path):
    wb = load_workbook(path, data_only=True)
    ws = wb.active
    hdr = [c.value for c in ws[1]]
    idx = {name: i for i, name in enumerate(hdr)}   # 按表头名找列,不写死列号
    groups = {}
    unknown = []
    skipped = []
    total = 0.0
    for r in ws.iter_rows(min_row=2, values_only=True):
        person = r[idx.get('报销人', 0)]
        subject = r[idx.get('事由', 2)]
        amt = r[idx.get('金额', 3)]
        # 脏行直接跳过并记录,不混进结果
        if (person is None or str(person).strip() == '') or amt is None:
            skipped.append(r)
            continue
        cat, hit_cats, words = match(subject)
        if cat is None:
            reason = ('多科目冲突:' + '/'.join(hit_cats) + f'(关键词{words})') if hit_cats else '无关键词命中'
            unknown.append((person, subject, amt, reason))
            groups.setdefault('其他/待人工确认', []).append((person, subject, amt))
        else:
            groups.setdefault(cat, []).append((person, subject, amt))
        total += float(amt)
    return groups, unknown, skipped, total

if __name__ == '__main__':
    groups, unknown, skipped, total = run(sys.argv[1])
    for cat in sorted(groups):
        rows = groups[cat]
        print(f'{cat}: {len(rows)} 笔 小计 {sum(float(x[2]) for x in rows):.2f}')
        for p, sub, amt in rows:
            print(f'    {p} | {sub} | {amt}')
    print(f'\n合计 {total:.2f}')
    print(f'未归类 {len(unknown)} 笔: {unknown}')
    print(f'跳过脏行 {len(skipped)} 行: {skipped}')

实跑里揪出来的真坑:一句话命中两个科目,猜就是错账

「会议礼品+快递费」同时命中业务招待费(礼品)和办公费(快递)。第一版脚本想当然地「取最长关键词」(礼品2字 vs 快递2字,等长排序还不稳定)——这种写法的问题是:它永远会给出一个答案,而且不告诉你这个答案是猜的。200 块进招待费还是办公费,账上就是两回事,猜错了没人发现。

修法:规则匹配系统必须有三态——命中 / 不命中 / 冲突。冲突时原样列出命中的所有科目和关键词(多科目冲突:业务招待费/办公费(关键词['礼品','快递'])),归入「待人工确认」,让人花 3 秒拍板。自动化省 95% 的时间,剩下 5% 恰恰是机器不该碰的。

另外两个小坑:

怎么定制到你公司的科目

改 RULES 一处即可:每个科目一行动词表。经验是关键词宁多勿少、每季度把「待人工确认」里的条目翻一遍——重复出现的新说法(比如「市内打车」vs「网约车」)补进词表,词表会越用越准。如果事由里金额和日期格式也乱,可以配合本站的考勤异常汇总同款「脏行挑出+报告」思路。

不想自己跑?把活儿给我。把你公司的科目表和几条真实报销事由发我,我写好脚本、用你的真实文件实测后交付(¥19.9 表格处理 / ¥29.9 脚本定制)。
→ 去下单页看看 | 虚拟物品交付后不退款;未收到或未达承诺免费补发/重做。

相关文章:会议纪要提取待办清单 · 值班表排班冲突检测 · 多Excel按关键字搜客户

← 更多实跑干货 | 喵窝资源铺