报名表×付款记录对账:报了没付、付了没报、金额对不上,一个脚本查清

2026-10-07 · 喵窝资源铺

场景:活动收报名费,报名表 12 人,微信/支付宝导出的付款记录 13 条。人工对账的噩梦全在这:陈大壮报了名一直没付、钱多多没报名却转了 300、李四少付 20、赵六手滑重复付了两次,最阴的是有人付款时把名字打错——「王小明」写成「王二明」,这笔钱就人间蒸发了。这篇给一个脚本(signup_pay_recon.py):报名表和付款表一起喂进去,同名 + 手机号兜底 + 形近字模糊三层对齐,一人多笔付款自动合并计总额(补差价不会误报),输出「报了没付 / 金额不符 / 付了没报名 / 核对通过」四张名单。代码实跑验证,真实输出见下。

完整脚本

先 pip install pandas openpyxl。报名表列:姓名 / 手机号 / 报名费(元);付款表列:姓名 / 付款时间 / 金额(元)(付款表没有手机号列也能跑,手机号兜底层自动跳过)。运行 python signup_pay_recon.py 报名.xlsx 付款.xlsx 对账结果.xlsx:

# -*- coding: utf-8 -*-
"""报名表×付款记录对账:报了没付 / 付了没报 / 金额对不上。
用法 python signup_pay_recon.py 报名.xlsx 付款.xlsx out.xlsx"""
import sys
import pandas as pd
from difflib import SequenceMatcher

def norm_name(s):
    return str(s).replace(" ", "").replace(" ", "").strip()

def sim(a, b):
    return SequenceMatcher(None, a, b).ratio()

def fuzzy_th(a, b):
    """阈值按名字长度分层:错一个字的相似度是 (n-1)/n。
    两字名错一字=0.5,三字名错一字=0.667(固定 0.75 会全漏!)。"""
    n = max(len(a), len(b))
    return 0.5 if n <= 2 else (n - 1) / n - 1e-9

def main():
    signup_fp, pay_fp, out_fp = sys.argv[1], sys.argv[2], sys.argv[3]
    # 手机号必须 str,否则 1.38e10 科学计数法对不上(见 roster 文的坑)
    su = pd.read_excel(signup_fp, dtype={"手机号": str})
    pay = pd.read_excel(pay_fp)

    su["_nname"] = su["姓名"].map(norm_name)
    pay["_nname"] = pay["姓名"].map(norm_name)
    pay["_matched"] = False
    pay["_tag"] = ""

    # ---- 三层对齐: 同名(合并同一人所有付款) -> 手机号 -> 形近字模糊 ----
    assign = {}  # 报名行 idx -> list(pay行 idx)

    # 1) 精确同名:一人可能付多次,同名全部算给他
    for i, r in su.iterrows():
        hits = pay[(~pay["_matched"]) & (pay["_nname"] == r["_nname"])].index.tolist()
        if hits:
            assign[i] = hits
            pay.loc[hits, ["_matched", "_tag"]] = [True, "同名"]

    # 2) 手机号兜底
    for i, r in su.iterrows():
        if i in assign:
            continue
        ph = r.get("手机号")
        if isinstance(ph, str) and ph.strip() and "手机号" in pay.columns:
            hits = pay[(~pay["_matched"]) & (pay["手机号"].astype(str) == ph)].index.tolist()
            if hits:
                assign[i] = hits
                pay.loc[hits, ["_matched", "_tag"]] = [True, "手机号"]

    # 3) 形近字模糊(阈值按名字长度分层),只对还没匹配上的
    n_fuzzy = 0
    for i, r in su.iterrows():
        if i in assign:
            continue
        hits = pay[(~pay["_matched"]) &
                   pay["_nname"].map(lambda x: sim(x, r["_nname"]) >= fuzzy_th(x, r["_nname"]))].index.tolist()
        if hits:
            assign[i] = hits
            pay.loc[hits, ["_matched", "_tag"]] = [True, f"模糊({r['_nname']}≈{pay.loc[hits[0],'_nname']})"]
            n_fuzzy += 1

    # ---- 输出四类名单 ----
    not_paid, mismatch, matched_rows = [], [], []

    for i, r in su.iterrows():
        hits = assign.get(i, [])
        fee = r.get("报名费(元)")
        if not hits:
            # 报了没付:付款侧剩余人名里找嫌疑错字,提示人工确认
            susp = [p for p in pay[~pay["_matched"]]["_nname"]
                    if sim(p, r["_nname"]) >= fuzzy_th(p, r["_nname"]) - 0.15]
            note = "未匹配付款记录" + (f";付款表疑似有「{susp[0]}」请人工确认" if susp else "")
            not_paid.append({"姓名": r["姓名"], "手机号": r.get("手机号", ""),
                             "应付": fee, "备注": note})
            continue
        total = sum(float(pay.loc[j, "金额(元)"]) for j in hits)
        fee = float(fee)
        if abs(total - fee) > 0.001:
            mismatch.append({"姓名": r["姓名"], "应付": fee, "实付": total,
                             "差额": round(total - fee, 2),
                             "明细": " + ".join(f"{pay.loc[j,'金额(元)']}元@{pay.loc[j,'付款时间']}" for j in hits)})
        matched_rows.append({"姓名": r["姓名"], "匹配方式": pay.loc[hits[0], "_tag"],
                             "付款笔数": len(hits), "实付合计": total, "应付": fee})

    extra = pay[~pay["_matched"]][["姓名", "付款时间", "金额(元)"]].to_dict("records")

    with pd.ExcelWriter(out_fp, engine="openpyxl") as w:
        pd.DataFrame(not_paid).to_excel(w, sheet_name="报了没付", index=False)
        pd.DataFrame(mismatch).to_excel(w, sheet_name="金额不符", index=False)
        pd.DataFrame(extra).to_excel(w, sheet_name="付了没报名", index=False)
        pd.DataFrame(matched_rows).to_excel(w, sheet_name="核对通过", index=False)

    # ---- 控制台摘要 ----
    print(f"报名 {len(su)} 人 / 付款记录 {len(pay)} 条 -> {out_fp}")
    print(f"对上 {len(assign)} 人(同名/手机号 {len(assign)-n_fuzzy},模糊 {n_fuzzy})")
    for x in not_paid:
        print(f"  报了没付: {x['姓名']}  {x['备注']}")
    for x in mismatch:
        print(f"  金额不符: {x['姓名']} 应付{x['应付']:g} 实付{x['实付']:g} 差{x['差额']:g} ({x['明细']})")
    for x in extra:
        print(f"  付了没报名: {x['姓名']} {x['金额(元)']}元 @ {x['付款时间']}")

if __name__ == "__main__":
    main()

实跑结果(12 人报名 / 13 条付款)

测试数据埋了 7 类真实情况:正常付款×7、少付(李四 100 付 80)、补差价分两次付(王五 100+50=150)、重复付(赵六 100+100)、付款错字×2(王小明→王二明、杨十二→阳十二)、报了没付×2(陈大壮、刘十一)、没报名却付了(钱多多 300)。跑出来:

报名 12 人 / 付款记录 13 条 -> 对账结果.xlsx
对上 10 人(同名/手机号 8,模糊 2)
--- 报了没付 2 人 ---
  陈大壮  未匹配付款记录
  刘十一  未匹配付款记录
--- 金额不符 2 人 ---
  李四 应付100 实付80 差-20 (80元@10-01 10:00)
  赵六 应付100 实付200 差100 (100元@10-01 12:00 + 100元@10-01 12:05)
--- 付了没报名 1 条 ---
  钱多多 300元 @ 10-02 10:00

逐项验收:陈大壮、刘十一精确落进「报了没付」;李四少付 20、赵六重复付差 +100(注意方向——差额 +100 是要退人家的,-20 才是要催的,两件事别搞混);钱多多的 300 落在「付了没报名」要联系退款;王五的补差价(100+50)合并后正好 150,没有误报;两个错字「王二明」「阳十二」都被模糊匹配抓回来并在核对通过表里标明(模糊(王小明≈王二明));「吴九 」的尾随空格被 norm_name 归一后正常同名匹配。幂等验证:同样输入连跑两次,输出的 xlsx 逐字节一致(cmp 通过)。

三个真坑(都是这轮实跑踩出来的)

模糊匹配到的人(王小明≈王二明)建议拿手机号或付款截图跟本人确认一次再入账,脚本报「核对通过」≠ 钱一定对——它只保证「这笔钱大概率是这个人的」。另外付款表没有手机号列时手机号兜底层自动跳过,不影响其余两层。

懒得自己跑?找我们代写
喵窝资源铺提供 Python 脚本代写服务:你说清楚两张表的格式和核对规则,我们写好、用你的真实数据测试通过再交付,24 小时内出活。AI 服务,明码标价,先看效果再付款(虚拟物品不退款,不满意免费重做)。
👉 了解代写服务 | 联系我们

📖 相邻文章:报名名单×签到表核对 · 微信群AA收款对账 · 工资表按身份证号跨文件对账 · Excel对账脚本 · 两张表人名模糊匹配合并 · 博客目录

对账结果供财务核对参考,退款/催款等操作请以实际付款凭证为准。