news 2026/10/1 22:10:33

检测机构月底关账,发票台账上报告已出未开票怎么自动对出来

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
检测机构月底关账,发票台账上报告已出未开票怎么自动对出来

月底关账那几天,财务在群里问的是同一句话:这个月报告出了、票还没开的,一共多少?这背后是三张表——客服的开票申请单、报告完成台账、财务的发票台账,各自在不同人手里,对起来只能一单单翻。

翻表只是累,真容易出事的是判断:报告出了没有?抬头、税号填全了吗?必须凭 PO 的客户,PO 号给了吗?申请金额跟台账算得一样吗?这一单是不是已经开过票?

为了解决这个问题,Python 提供了 openpyxl:读三张 CSV 表,把这些判定写成代码,再把结果写回一张 Excel 台账。不用另装数据库,也不碰开票系统——它只告诉你这批能开哪几张、剩下的卡在哪一步。

本文按四步走:配置路径与字段 → 读三张表归堆 → 逐单判够不够格 → 出清单和对账台账,代码取自配套示例的run.py。

一、开票卡的不是"开",是"够不够格"

开票本身是在开票软件里点几下的事。麻烦在点之前:单子由客服一单一单递过来,报告出没出、开过没有,没人说得准。

口径还按客户分:有的按委托单逐张开,有的按月汇总一张。

作废票最容易形成死结:上月开错一张、红冲后要重开,一到「已经开过票」就被拦住。第一条规矩就是——只看状态正常的票,作废票既不占额度、也不挡重开。

二、第一步:把路径和字段写在一个地方

出处:run.py顶部的路径与字段常量区

REQ_CSV = RAW_DIR / "开票申请单.csv" # 客服/业务交上来的开票申请 REP_CSV = RAW_DIR / "报告完成台账.csv" # 已出报告(出了报告才有开票依据) INV_CSV = RAW_DIR / "已开发票台账.csv" # 财务已经开出去的票(含作废) RULE_CSV = RAW_DIR / "客户开票规则表.csv" # 客户 → 开票方式 / 税率 / 结算周期 / 是否必须 PO TPL_XLSX = TPL_DIR / "开票对账台账模板.xlsx" LEDGER_XLSX = OUT_DIR / "开票对账台账.xlsx" IMPORT_CSV = OUT_DIR / "开票数据_导入用.csv" # 给开票软件导入用

出处:run.py顶部的路径与字段常量区(判空列那一段)

REQ_HEAD = "申请日期" REP_HEAD = "委托单号" INV_HEAD = "发票号码" RULE_HEAD = "客户名称"

路径都从Path(__file__).resolve().parent往下接,换机器不用改。申请单天天变,规则表几个月才动一次,四张表分开放,改口径不用碰代码。判空列那四行看着多余,其实是整段脚本里最要紧的一处防护。

三、第二步:读三张表,先归堆

出处:run.py的read_csv_rows()(在norm()后面)

def read_csv_rows(path, head_col): """读一张 CSV 成 dict 列表,全部按文本取。 **一律不转类型**——委托单号 WT2026-0000715、发票号 00012301 一转 int 前导零就没了, 跟开票软件里的单号对不上。表尾的说明行靠 `head_col` 那一列为空跳过,所以说明文字别写进那一列。 """ rows = [] with open(path, "r", encoding="utf-8-sig", newline="") as f: for raw in csv.DictReader(f): if not raw or not norm(raw.get(head_col, "")): continue rows.append({k: ("" if v is None else str(v)) for k, v in raw.items()}) return rows

为什么要单独传head_col:表尾说明靠「哪一列一定有值」跳过。给客户规则表判空时顺手用「申请日期」,那张表根本没这一列——每行取出来都是空,整张规则表被读成空的,所有单子都挂「客户没进规则表」。换表先问:靠哪一列判空。

出处:run.py的to_money()与tax_rate()(紧跟norm())

def to_money(text): """把表里的金额文本转成两位小数。带千分位逗号和 ¥ 的照样读——系统导出来就长这样。""" s = str(text).strip().replace(",", "").replace("¥", "").replace("¥", "") if not s: return 0.0 return round(float(s), 2) def tax_rate(text): """税率写成「6%」也能读——导出表里常带百分号,直接 float 会炸。""" return round(float(str(text).strip().replace("%", "")) / 100, 4)

金额带千分位逗号、税率带百分号是导出表的常态,两个float()都会抛异常;先洗再转,空值当 0。

出处:run.py的read_rules()(在read_invoices()后面)

def read_rules(): """客户开票规则表:客户 → 开票方式 / 税率 / 结算周期 / 是否必须 PO。 客户名当键时压成"去全角空格"的比较键,显示名另外存一份干净的—— 同一家客户在申请单里多打一个空格,本来会被当成两家。 """ rules = {} for row in read_csv_rows(RULE_CSV, RULE_HEAD): rules[norm(row["客户名称"])] = { "客户名称": BLANK_RE.sub("", row["客户名称"].strip()), "开票方式": row["开票方式"].strip(), "税率": row["税率"].strip(), "结算周期": row["结算周期"].strip(), "是否必须PO": row["是否必须PO"].strip(), } return rules

客户名当键要归一化:norm()去掉所有空白,含全角空格(strip()不管它)。规则表里名字带个全角空格、申请单里不带,不归一化就是两家客户,两边单子全挂。别上模糊匹配——这行的客户名本来就长得很像。

出处:run.py的contract_used()(紧跟read_rules())

def contract_used(invoices, reports): """各合同已经占掉的额度(不含税):只算状态正常的票,作废票不占。""" used = {} for inv in invoices: if inv["状态"].strip() != "正常": continue rep = reports.get(norm(inv["关联委托单"])) if rep: key = norm(rep["合同号"]) used[key] = used.get(key, 0.0) + to_money(rep["不含税金额"]) return used

额度按不含税累计,拿价税合计去比会提前报超;作废票在这里也跳过,否则额度被白占。

四、第三步:逐单判够不够格

出处:run.py的plan_one()(文件中部,资料与金额两组判定)

cust = norm(req["客户名称"]) if cust not in rules: return None, "客户规则", "这家客户没进开票规则表,先定开票方式与税率再开" rule = rules[cust] no = norm(req["委托单号"]) if not req["发票抬头"].strip() or not req["纳税人识别号"].strip(): return None, "开票信息", "发票抬头或纳税人识别号空着,开票软件这一步就过不去" rep = reports.get(no) if rep is None: # 出了报告才有开票依据 return None, "报告未出", "报告完成台账里没有这一单,报告没出不能先开票" if rule["是否必须PO"] == "是" and not req["PO号"].strip(): return None, "缺PO", f"这家客户必须凭 PO 开票,申请单上 PO 号空着" tax = tax_rate(rule["税率"]) net = to_money(rep["不含税金额"]) due = round(net * (1 + tax), 2) # 申请金额应当等于 不含税 ×(1+税率) want = to_money(req["申请开票金额"]) if abs(want - due) > 0.005: return None, "金额不符", f"申请金额 {want:,.2f},台账算出来是 {due:,.2f},差 {abs(want - due):,.2f}"

出处:run.py的plan_one()(文件中部,重票与额度那一组判定)

if no in opened: # 作废票不算开过,重开要走到这里 return None, "重复开票", f"这一单已经开过票({opened[no]}),再开一次就是重票" key = norm(rep["合同号"]) cap = to_money(rep["合同额"]) used = used_net.get(key, 0.0) if used + net > cap + 0.005: return None, "超合同额度", (f"合同 {key} 额度 {cap:,.2f}(不含税),已占 {used:,.2f}," f"这一单 {net:,.2f} 会超")

判定顺序就是业务顺序,这是唯一不能自己发挥的地方:

判定(按顺序)卡住什么
委托单号单号空着,和开票软件里的单子对不上
客户规则新客户没定开票方式与税率,先定再开
开票信息抬头、纳税人识别号空着,开票软件过不去
报告未出报告完成台账里没有这一单,不能先开票
缺PO必须凭 PO 的客户 PO 号没给,开出去客户不认
金额不符申请金额与台账算出来的差多少,当场指出来
重复开票已开过正常票,再开一次就是重票
超合同额度不含税额度不够,得先签补充协议

顺序反了,理由就指向错误的动作:把额度排在报告前面,客服拿着「额度不够」去签补充协议,回来才发现这单报告还在审核。「重复开票」放后面也有讲究——前面全通过了才轮到它。

五、第四步:出清单,再算关账差额

可开票的进第一张表,挂起的进第二张,第三张是按客户的关账对账。

出处:run.py的build_recon()(文件后半,先算已开、再汇总)

opened = {} for inv in invoices: if inv["状态"].strip() != "正常": continue rep = reports.get(norm(inv["关联委托单"])) if rep: opened[norm(inv["关联委托单"])] = to_money(rep["不含税金额"]) names, total, invo, cnt = {}, {}, {}, {} for rep in reports.values(): key = norm(rep["客户名称"]) names[key] = BLANK_RE.sub("", rep["客户名称"].strip()) net = to_money(rep["不含税金额"]) total[key] = total.get(key, 0.0) + net if norm(rep["委托单号"]) not in opened: cnt[key] = cnt.get(key, 0) + 1 for no_ in opened: rep = reports[no_] key = norm(rep["客户名称"]) invo[key] = invo.get(key, 0.0) + opened[no_]

作废票三处都要跳过:额度(不占)、重票判断(不挡重开)、已开金额(不算已开)。第三处漏了,差额就偏小。

出处:run.py的write_ledger()(文件后半,第一张表那一段)

ws = wb["可开票清单"] for item in items: ws.append([item["委托单号"], item["客户名称"], item["合同号"], item["PO号"], item["不含税金额"], item["税率"], item["税额"], item["价税合计"], item["发票抬头"], item["纳税人识别号"], item["开票方式"], item["结算周期"]]) for c in ws[ws.max_row]: c.font = Font(name="微软雅黑", size=10) c.border = border if c.column == 1: c.number_format = "@" # 委托单号带前导零,当文本存 c.alignment = Alignment(horizontal="left") elif c.column in (5, 7, 8): c.number_format = "#,##0.00"

委托单号那列必须设成文本格式@:一旦有人转成数字,前导零没了,跟开票软件里的单子对不上。另外还出一份02_output/开票数据_导入用.csv,给开票软件导入用。

总结

一,先定判断,再谈开票:能不能开按业务顺序过一遍七项判定,挂起理由指名缺什么、差多少。

二,作废票要三处都跳过:不占额度、不挡重开、不算已开金额;第三处漏了,关账差额就偏小。

三,口径按客户走:开票方式、税率、是否凭 PO、合同不含税额度都在规则表里,换客户改表不改代码。

示例回读 34 项全绿,换掉01_raw_data/里的四张表就能直接用。

完整源码

本文配套的可运行示例已开源,换掉01_raw_data/里的申请单、报告台账、已开发票台账和客户规则表就能直接复跑:

huang_jianhua0101/examples - Gitee.com

关于我

在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。

本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。

现在主要做这几类:
- 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错
- 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表
- 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对
- 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上

手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 22:09:43

OpenCV安装教程:pip/conda/源码编译与多平台避坑

1. 先搞清楚你要装的是哪一类OpenCVopencv安装教程在网上能搜出几百个版本,但真正让新手卡住的从来不是"敲哪条命令",而是没弄清楚自己要装的是哪一类OpenCV。我见过太多人对着一个报错折腾一整天,最后发现是包选错了,或…

作者头像 李华
网站建设 2026/10/1 22:06:39

devtool热部署

方案一:使用 Spring 官方热部署(首选,免费) 直接使用 Spring 官方提供的 Spring Boot DevTools。 应该用哪个版本: 必须使用与你项目 Spring Boot 完全一致的版本。 引入方式: 如果你是 Maven 项目,直接在 pom.xml 中引入(无需手动写死版本号,它会自动继承父版本)…

作者头像 李华
网站建设 2026/10/1 22:04:55

【Linux笔记】Linux进程与守护进程

一、 进程组1.1 进程组的本质进程组:是一个或多个进程的集合,主要用于作业控制。进程组具有以下特征:唯一标识:每个进程组拥有一个进程组 ID(PGID),其数据类型与 PID 完全相同。成员关系&#x…

作者头像 李华
网站建设 2026/10/1 22:01:41

053级联合并排序

级联合并排序 (Cascade Merge Sort) 053级联合并排序:瀑布流的排序智慧故事:瀑布流的智慧 想象一条山间瀑布:水从高处的宽大水潭开始,分成多股支流奔涌而下,每一级台阶都比上一级更窄,但最终所有水流在山脚…

作者头像 李华
网站建设 2026/10/1 21:57:53

从监工到派活:用Claude实现自动化任务执行的高效方法

1. 从“监工模式”到“派活模式”:为什么你该换一种方式用 Claude大多数人用 Claude 的方式,本质上跟盯工地没什么区别。打开对话框,敲一句“帮我写个函数”,等它吐出来,看一眼,不满意,再补一句…

作者头像 李华