乐企版式文件生成平台

0 阅读10分钟

我做了一个电子发票生成工作台:支持 PDF / OFD / XML

先讲一个我们公司每个月都要上演的场景。

季度末的下午,财务的企业微信又弹过来一句:“XX 客户那张发票麻烦再发一下,销售催得急。”

我太知道接下来会发生什么了:财务登录乐企平台,在一堆发票里翻出那一张,下载 OFD / PDF,传到公盘,在群里 @ 销售,销售下载下来,再转手发给客户。

一张票,五个动作,跨三个人。​ 月底几百张的时候,这套“人肉接力”就成了灾难。

我们公司财务其实早就上了乐企直连开票,开票这一环已经非常顺了。但我慢慢发现:乐企把“开票”做到了极致,“把票交到客户手里”这一段,却还是手工活。

image.png

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

image.png

后端用 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,还配套了绿页阅读器之类的验签工具。

但这类方案有共同特征:商业付费(按张或包年)、产品很重、要走采购和对接流程,面向的是"全都要"的大中型客户。而我们的处境很具体——乐企开票已经搞定,只差"用票交付"这一小段的自动化。为这一个环节去引入一整套商业中台,成本和周期都不划算。

维度登录乐企手动下载百望 / 航信等商业平台自建版式生成器
成本零,但费人按张/包年付费,偏重一次性开发,零增量费用
批量 / 自助几乎没有贴合自己流程定制
合规与规范跟进税局官方,最稳厂商同步更新需自己跟进规范变化
适用场景偶发、零星大中型、全场景单点痛点、内部提效

前端:不是按钮页,是工作流

image.png

前端我没做成"一排按钮糊在页面上",而是顺着真实动线排:顶部看服务状态和流程导航,左侧导入和编辑数据,右侧给校验结果与下载入口,中间铺开发票概览和明细,底部是票面预览和操作日志。目标只有一个:让一个藏在后端的能力,变成销售能直接上手、客户能自助拿票的产品。

  • 顶部展示服务状态和流程导航
  • 左侧导入和编辑 JSON
  • 右侧展示校验结果与下载文件
  • 中部展示发票概览和项目明细
  • 底部提供票面预览和操作日志

这个设计的目标是让一个“后端文件生成能力”变成一个可以被使用、被演示、被理解的产品。

image.png

这个项目到底证明了什么

它适合放进作品集,不是因为技术多炫,而是因为它把一个真实的业务断点完整补上了:有清晰的业务场景(用票交付的人肉接力)、有跑得通的前后端闭环、有对数电票版式规范这种"魔鬼细节"的实现、有 PDF / OFD / XML 多格式输出、有 Python ↔ Java 的跨语言集成,还有对行业竞对(百望 / 航信)的判断和清醒的自我定位。

对个人项目来说,这比“我写了一个接口”有说服力得多。

后续想继续打磨的

补更完整的测试用例、把版式规范的校验做得更严(尤其是特定要素发票)、支持批量数据导入与批量打包、多模板票面切换、Docker 部署和在线演示页,以及更清晰的截图和架构图。

写在最后

Leqi Invoice Studio 不是要替代正式财税系统,也不是要和百望、航信掰手腕。它解决的是一个很具体、却天天发生的问题:乐企直连把开票做顺了,但"把票交给客户"这一步,还在靠人。 我把这一步自动化掉了。

从财务那句"麻烦再发一下",到现在销售自己点一下就拿到三种格式——这中间的工程量,就是我最想展示的东西。

如果你们公司也遇到相关的难题,欢迎探讨一下。