账务中台 · 2026-08 · 本地离线部署

把各家对账单
做成一套能审计的账

5 家机构、4 种导出格式、2129 笔流水。期初建账后,账面余额与单据末行余额差 0.00 —— 账实相符是判断一套账做没做对的唯一标准。

期初建账 · 账面 vs 单据末行
node scripts/backfill-opening-balance.mjs ✓ 1001 银行存款-A 机构 期初 2549.41 ✓ 1003 银行存款-B 机构 期初 9520.92 ✓ 1005 银行存款-C 机构 期初 18.71 试算平衡: 对照各单据末行余额 A 机构 账面 268.03 单据 268.030.00 B 机构 账面 3.77 单据 3.770.00 C 机构 账面 2.78 单据 2.780.00
入账凭证
21295 个批次,跨 4 种文件格式
解析告警
0三重自校验全部通过
测试
155守护账本不变量,全绿

问题

单式记账会把「垫付—报销」记成收入

公司拨一笔备用金到经办人账户 → 经办人先行垫付采购 → 事后凭票报销回款。 同一笔资金走完这一圈,单式记账会把「备用金到账」和「报销回款」各记一次收入, 把垫付记成一次成本 —— 收入凭空翻倍,成本重复计入。

这类往来在任何有备用金、代垫、项目预付、多主体归集的场景里天天发生。 它不是加几个分类字段能解决的,是数据模型本身缺少表达能力。

证据先于解释

原始单据 → 原始流水(只追加、不可修改)→ 标准化 → 记账凭证。每条结果都能回溯到文件名、页码、行号。

复式记账

备用金到账记「银行存款增加 / 其他应付款增加」,报销回款记「银行存款增加 / 其他应收款减少」—— 两笔都不碰收入科目,垫付也只在往来科目里进出。收入与成本因此不会被重复计入。

改错走冲正

已过账凭证不可修改删除。科目改了就生成红字冲销加新分录,前后差异写入审计日志。

实测

版式解析与三重自校验

每家机构的导出格式都不一样,而且都不会告诉你解析对没对。所以每个解析器都要 自己证明自己没错:单据里本来就有的合计行、笔数、余额链,全部拿来做交叉验证。

解析器格式笔数自校验方式结果
A 机构 · 账户流水PDF 20 页436末页合并统计 + 余额链 436/4360 告警
A 机构 · 账户流水XLSX437表头校验 + 金额精度断言0 告警
B 机构 · 活期明细XLS639收支合计行比对0 告警
C 机构 · 账户对账单PDF 加密58余额链 + 声明合计 + 声明笔数0 告警
D 机构 · 月结对账单邮件文本722账单勾稽式 6 期 6/6 闭合0 告警

余额链验证

逐笔断言「上一笔余额 + 本笔金额 = 本笔余额」。某份 20 页 PDF 的 436 环全部连续,等于数学证明没漏行、没切错数字。

声明合计比对

单据末尾本来就印着收支合计,拿来和解析结果对账。声明合计与解析结果分毫不差才放行。

声明笔数比对

对账单页脚写着「共 58 笔」,必须解析出 58 笔才算通过。三道校验任一不符即告警,不会静默出错。

踩过的坑

只有真实文件才会暴露的问题

① 换个格式重导,163 笔重复只识别出 2 笔

同一笔交易,XLSX 版的对手方写「样例公司 12345」,PDF 版写成「样例公 司 12345 客户摘要」—— 文本形态不同,指纹就不同,重复导入没被拦住。 如果没发现,每换一种格式重导一次就会重复记一次账。

指纹算法修复前后(同一份 PDF)
修复前 新增 434 去重 2 ← 实际重复 163 笔,漏了 161 修复后 新增 273 去重 163 ← 精确识别,增量正确 指纹改用跨格式稳定的分量 机构 + 账户(去分隔符) + 日期 + 金额 + 币种 + 交易后余额 + 规范化摘要 + 对手账号数字串

② PDF 里的中文抽不出来

某家机构的 PDF 用 CID 字体,常见的 serverless PDF 库不带 CMap 字库,抽出来是空字符串; 换成带完整字库的 pdfjs-dist legacy 构建才拿到文本。另有一家导出的 .xls 实际是 .NET OpenXML 生成、带 x: 命名空间前缀,主流 Node Excel 库直接崩在读取阶段。

③ 三段数字粘连在一起

某份对账单 PDF 的文本层里,金额、余额、户名之间没有空格: 2025-08-25+4,320.904,320.90某某公司 6217…转账。 靠正则切分,再用余额链兜底 —— 一旦切错,余额链立刻断裂并报警。

成本核算

账是真的,成本就不用猜了

账实相符之后,很多原本靠对方口头告知的数字变成了可复核的:扫码收款、平台结算提现、 分期采购,每条渠道的实际成本都能从凭证里逐笔倒推出来。以下都是从上面这批数据跑出来的:

核算项方法结果
收款渠道手续费核算收款金额与实际到账金额逐笔配对,20 组0.300% / 0.600% 两档,均值 0.532%
分期账单真实成本名义手续费率按实际现金流求 IRR名义「8%」→ 实际年化 14.5%
分期余额反解由总价、期数、每期金额反解费率与各期未结余额与第三方账面数交叉验证,差 4 元
在途分期台账由月结单逐期明细还原未结清项与剩余期数12 项,进行中 / 已结清 / 已退货三态自动收敛

第二行值得展开:商家报的「12 期 8% 手续费」是按总价平摊的名义口径,而本金是逐期归还的 —— 第 6 期时你只占用了一半资金,却仍按全额付手续费。按实际占用天数折算, 真实年化是 14.5%,接近名义值的两倍。这类口径差在设备采购、渠道账期里普遍存在。 第三行则是三个互相独立的来源彼此印证:总价、期数、每期金额反解出的未结余额, 与第三方账面数相差 4 元,说明这套账确实算对了。

资产核算

一台设备到底一天花多少钱

分期采购的手机、笔记本、服务器,账上通常只留下每月一笔扣款,很难回答两个真问题: 这台设备每天的持有成本是多少什么时候换掉最划算

把采购凭证、分期明细与处置回款挂到同一台设备上,这两个问题就变成了减法。 以一台分期采购、一年后转手的手机为例:

设备持有成本核算 · 示例口径
采购总价(含分期手续费) A 持有天数 D 处置回款(二手转让) B 日均持有成本 = (A − B) ÷ D 置换判据: · 新机日均成本 < 旧机继续持有的日均成本 → 置换更省 · 二手残值随时间下降,越晚出手回款越低,置换窗口有最优区间 · 分期未结余额需一并计入,否则会低估持有成本

凭证挂到实物

采购、分期扣款、维修、保险、处置回款全部关联到同一台设备,形成完整生命周期账。

不在账单里的也能记

车辆保险、年检、里程这类不走对账单的支出支持手工录入,与自动导入的凭证同表核算。

处置损益自动结转

转手时按账面净值与实际回款自动生成处置损益分录,不需要人工算差额。

技术栈

怎么搭的

pnpm workspace · 11,578 行 TypeScript
apps/api NestJS 11 + Prisma 6 + PostgreSQL 16(只监听 127.0.0.1) apps/private-lab Next.js 15 + React 19 + Tailwind v4 packages/domain 金额 bigint 最小单位 · 民用日期 · 账期纯函数 packages/ledger 科目表 · 复式分录 · 冲正 · 试算平衡 packages/importers 解析器注册表(按机构+版式+版本隔离)· 指纹去重 packages/reconciliation 确定性规则分类器(14 类交易性质)· 结算配对 packages/cost-engine 摊还计划 · IRR · 名义费率转真实资金成本

金额不用浮点数

全程 bigint 最小货币单位。只在解析边界一次性转换,小数位超出币种精度直接抛错,绝不静默舍入。

解析器按版式版本化

同一家机构的不同导出格式各自独立、各带版本号。新版解析器不会静默重写历史导入结果。

提交前敏感数据闸门

pre-commit 钩子拦截真实单据路径与 16–19 位长数字(卡号/证件号)。开发期间它拦下过 4 次,包括作者自己写的测试夹具。