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% 恰恰是机器不该碰的。
另外两个小坑:
idx.get('事由') 这类映射,表头挪了也不炸;找不到列名时兜底默认列号。None、纯空格 ' '、空串 '' 在事由列里都见过。统一 str(x).strip() 后判空,别只判 is None。改 RULES 一处即可:每个科目一行动词表。经验是关键词宁多勿少、每季度把「待人工确认」里的条目翻一遍——重复出现的新说法(比如「市内打车」vs「网约车」)补进词表,词表会越用越准。如果事由里金额和日期格式也乱,可以配合本站的考勤异常汇总同款「脏行挑出+报告」思路。
相关文章:会议纪要提取待办清单 · 值班表排班冲突检测 · 多Excel按关键字搜客户