我做了一个电子发票生成工作台:支持 PDF / OFD / XML
先讲一个我们公司每个月都要上演的场景。
季度末的下午,财务的企业微信又弹过来一句:“XX 客户那张发票麻烦再发一下,销售催得急。”
我太知道接下来会发生什么了:财务登录乐企平台,在一堆发票里翻出那一张,下载 OFD / PDF,传到公盘,在群里 @ 销售,销售下载下来,再转手发给客户。
一张票,五个动作,跨三个人。 月底几百张的时候,这套“人肉接力”就成了灾难。
我们公司财务其实早就上了乐企直连开票,开票这一环已经非常顺了。但我慢慢发现:乐企把“开票”做到了极致,“把票交到客户手里”这一段,却还是手工活。
Leqi Invoice Studio(版式文件生成器)就是为了补上这一段而做的——把已经开好的发票数据,一键生成可预览、可下载、可归档的 PDF / OFD / XML,让销售甚至客户自助拿票,不用再麻烦财务一张张去下。
声明:本项目仅用于公司内部、对本企业已合法开具的发票做版式生成与交付,用于技术学习、接口联调和版式验证,不用于任何非法票据的生成、伪造或冒用。版式文件仅作展示与交付载体,发票的法定效力以税务系统中的 XML 数据为准。
先说清楚:为什么"开完票"还不算完
很多人有个误解,觉得数电票时代"开票即交付"——票一开,自动进对方数字账户,完事。
这话对一半。数电票的本质是一份 XML,它只装财务数据,不带任何版式信息,法定效力也在这份 XML 上。但现实里的 B2B 交付,客户和销售要的往往是一张能打开、能看、能打印、能附在合同后面的 PDF 或 OFD。这个"给人看的版式文件",在乐企模式下有个很关键的设定:
乐企开票时,XML 文件及 PDF、OFD 版式文件需要企业自行组装、生成,对企业有一定的技术能力要求。
也就是说,税局把“票”的数据给你了,但那张排版精确、盖着监制章、能交给客户的版式文件,得你自己生成。我们公司当时图省事,走的是"登录乐企平台手动下载"这条路。能用,但繁琐,而且完全没法批量、没法自助。
把那条又长又费人的链路画出来,大概是这样:
flowchart LR
A[财务登录乐企平台] --> B[逐张下载 PDF/OFD]
B --> C[上传到公盘]
C --> D[群里通知销售]
D --> E[销售下载]
E --> F[转发给客户]
每一个箭头都是一次等待、一次切换、一个可能掉链子的地方。客户没收到票来催,八成就卡在了中间某一棒。
我想把它压成这样
数据进来,校验、生成、打包,销售自己点一下就拿到 ZIP。财务从这条链路里彻底解放出来。核心流程一句话:导入开票数据 → 校验字段 → 预览票面 → 下载 PDF / OFD / XML / ZIP
flowchart LR
A[导入开票数据/报文] --> B[校验字段] --> C[生成 PDF/OFD/XML] --> D[销售/客户自助下载 ZIP]
把一张票"画"对,到底有多难
这部分是整个项目我最想讲的——生成版式文件,真不是把字段往模板里一填那么简单。 数电票版式有一套相当较真的国家规范,差一点,票面就"不对"。我在实现时啃下来的一些硬指标:
- 票面尺寸固定为 210mm × 140mm(最小高度),内框 201mm × 94mm,纵向分票头、购销方、明细合计、备注、票尾五段;
- 明细行规则:不超过 8 行时高度固定 140mm,超过 8 行,高度要按超出的行数往下加;
- 监制章:椭圆 30mm × 20mm,楷体 7 磅,大红色,字圈层次都有讲究;
- 右框色带:金色 RGB(246, 237, 225)、咖色 RGB(118, 89, 84),高 0.54mm,长度为右框线的 1/2;
- 字体字号:纳税人识别号用 Courier New 12pt,人民币符号 Courier New 11pt,项目明细宋体 9pt,文本标签用楷体;
- 交付时效:版式交付时间与上传时间误差不能超过 48 小时。
我把这些规范固化成了一组常量和规则,而不是散落在代码各处的魔法数字:
# 数电票(基础版)版式规范,单位 mm
INVOICE_WIDTH = 210
INVOICE_MIN_HEIGHT = 140
INNER_FRAME = (201, 94)
DETAIL_BASE_ROWS = 8 # 明细 8 行以内高度固定
SEAL_SIZE = (30, 20) # 监制章椭圆尺寸
SEAL_COLOR = (216, 30, 30) # 大红
BAND_GOLD = (246, 237, 225) # 右框金色色带
BAND_COFFEE = (118, 89, 84) # 右框咖色色带
光“明细超过 8 行要动态加高”这一条,就得让票面高度跟着数据走:
def invoice_height(detail_rows: int, row_height: float = 6.0) -> float:
"""明细 8 行内高度固定,超出部分按行追加。"""
extra = max(0, detail_rows - DETAIL_BASE_ROWS)
return INVOICE_MIN_HEIGHT + extra * row_height
OFD 上的坑更细。业内不少版式工具生成的 OFD 有通病:
- 监制章是位图而不是矢量,放大就糊、文件还大;
- 字体、印章资源重复嵌入,体积虚高。
- 要做对,印章得用矢量
文字超长时缩字号但保持行高不变、相同资源做去重————这些都是"看不见但一打印就露馅"的地方。
你还需要确认:
- 字段是否完整
- 金额、税额和明细项是否合理
- 中文内容是否乱码
- PDF 票面是否符合预期
- OFD 文件是否能生成
- XML 是否能作为结构化交付物输出
- 最终文件名和下载方式是否适合归档
如果每一步都靠临时脚本或手动调用接口,效率会很低,也不容易演示给别人看。所以我把这些能力做成了一个本地工作台。
架构:Python 编排,OFD 这块交给 Java
后端用 FastAPI,能力收敛在 /api/v1/invoices 一组接口下:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/invoices/sample | 获取示例数据 |
| POST | /api/v1/invoices/validate | 校验发票数据 |
| POST | /api/v1/invoices/preview | 生成 PDF 预览 |
| POST | /api/v1/invoices/pdf …/ofd …/xml | 下载对应格式 |
| POST | /api/v1/invoices/archive | 打包下载 ZIP |
这里有个刻意的设计:校验和生成分开。 validate 只回结构化错误、不产文件,前端“先校验再生成”,避免拿一份残缺数据去跑昂贵的渲染。
格式分工上,PDF 和 XML 我用 Python 直接生成,但 OFD 我没有硬啃。OFD 是国产版式标准(GB/T 33190),成熟的开源实现(比如 ofdrw)主要沉淀在 Java 生态里。与其在 Python 里重造一个不靠谱的轮子,不如让专业的工具干专业的事:
FastAPI
├── PDF generator (Python)
├── XML generator (Python)
└── OFD renderer (Java 子进程, 基于 ofdrw)
具体做法是 Python 把发票数据写进临时 JSON,通过 subprocess 调用 Java 的 JAR,拿回渲染好的二进制:
def render_ofd(invoice: dict) -> bytes:
with tempfile.NamedTemporaryFile(
"w", suffix=".json", encoding="utf-8", delete=False
) as f:
json.dump(invoice, f, ensure_ascii=False)
json_path = f.name
try:
result = subprocess.run(
["java", "-jar", OFD_RENDERER_JAR, json_path],
capture_output=True, timeout=20, check=True,
)
return result.stdout
finally:
os.remove(json_path)
这是个有意识的取舍:用一点跨进程开销,换来 Python 服务保持简洁、OFD 渲染逻辑独立演进,两边各管各的。
还有个中文业务绕不开的坑:编码和文件名。不同系统导出的数据,编码五花八门,我做了 utf-8-sig → utf-8 → gb18030 的回退解码;下载文件名则用 ASCII 兜底 + RFC 5987 的 filename* 双写,让浏览器尽量保留中文名,下载下来直接是"发票号_购买方_时间戳",不用改名就能归档:
from urllib.parse import quote
def build_content_disposition(filename: str) -> str:
ascii_fallback = filename.encode("ascii", "ignore").decode() or "invoice"
return f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{quote(filename)}'
那为什么不直接买百望 / 航信?
这是同事和面试官都会问的问题,也是我特意想清楚过的。
电子发票这个领域,航天信息(航信)和百望是公认的双寡头:税控时代两家存量市占率大约 7:3,航信的电子发票开具量一度占到全国一半以上;百望云本身就是国家税务总局电子发票服务平台的建设供应商之一。
它们,以及开灵这类服务商,都提供成熟的"乐企前置 / 版式中台",内置全部版式规范,能按标签自动生成合规的 PDF / OFD,还配套了绿页阅读器之类的验签工具。
但这类方案有共同特征:商业付费(按张或包年)、产品很重、要走采购和对接流程,面向的是"全都要"的大中型客户。而我们的处境很具体——乐企开票已经搞定,只差"用票交付"这一小段的自动化。为这一个环节去引入一整套商业中台,成本和周期都不划算。
| 维度 | 登录乐企手动下载 | 百望 / 航信等商业平台 | 自建版式生成器 |
|---|---|---|---|
| 成本 | 零,但费人 | 按张/包年付费,偏重 | 一次性开发,零增量费用 |
| 批量 / 自助 | 几乎没有 | 强 | 贴合自己流程定制 |
| 合规与规范跟进 | 税局官方,最稳 | 厂商同步更新 | 需自己跟进规范变化 |
| 适用场景 | 偶发、零星 | 大中型、全场景 | 单点痛点、内部提效 |
前端:不是按钮页,是工作流
前端我没做成"一排按钮糊在页面上",而是顺着真实动线排:顶部看服务状态和流程导航,左侧导入和编辑数据,右侧给校验结果与下载入口,中间铺开发票概览和明细,底部是票面预览和操作日志。目标只有一个:让一个藏在后端的能力,变成销售能直接上手、客户能自助拿票的产品。
- 顶部展示服务状态和流程导航
- 左侧导入和编辑 JSON
- 右侧展示校验结果与下载文件
- 中部展示发票概览和项目明细
- 底部提供票面预览和操作日志
这个设计的目标是让一个“后端文件生成能力”变成一个可以被使用、被演示、被理解的产品。
这个项目到底证明了什么
它适合放进作品集,不是因为技术多炫,而是因为它把一个真实的业务断点完整补上了:有清晰的业务场景(用票交付的人肉接力)、有跑得通的前后端闭环、有对数电票版式规范这种"魔鬼细节"的实现、有 PDF / OFD / XML 多格式输出、有 Python ↔ Java 的跨语言集成,还有对行业竞对(百望 / 航信)的判断和清醒的自我定位。
对个人项目来说,这比“我写了一个接口”有说服力得多。
后续想继续打磨的
补更完整的测试用例、把版式规范的校验做得更严(尤其是特定要素发票)、支持批量数据导入与批量打包、多模板票面切换、Docker 部署和在线演示页,以及更清晰的截图和架构图。
写在最后
Leqi Invoice Studio 不是要替代正式财税系统,也不是要和百望、航信掰手腕。它解决的是一个很具体、却天天发生的问题:乐企直连把开票做顺了,但"把票交给客户"这一步,还在靠人。 我把这一步自动化掉了。
从财务那句"麻烦再发一下",到现在销售自己点一下就拿到三种格式——这中间的工程量,就是我最想展示的东西。
如果你们公司也遇到相关的难题,欢迎探讨一下。