AI辅助的ERP迁移 —— 真实案例研究

教程:我们把自己的ERP账单砍到零——一份完整的实地调查报告

编者按。 这是我们自己的账目。在向任何人推荐这条路径之前,我们先迁移了自己的账本——文中每一个数字都是真实且未经删改的。本文由AI大量协助撰写;关于这具体意味着什么、以及我们为何认为这一点重要,请见下文"关于本文写作方式的说明"。

写给正在权衡这篇文章是否值得一读的创始人

如果你的公司运行在Odoo企业版、Salesforce、NetSuite或任何按用户计费的SaaS型ERP上,你大概至少认真算过一次账:离开它到底要付出多大代价?

诚实的答案是:许可证费用只是很小的一部分。真正的成本在于迁移本身——具体来说,是你的账目在到达另一端时悄悄出错、却没人察觉,直到审计师发现为止的那种风险。

这是对一次这样的迁移——我们自己的迁移——的完整记录,在一个硬性截止日期下,于单次工作会话中执行,后续工作又延续了一天。最终结果是:全部78个科目的试算平衡表与源系统的差额控制在九分钱以内,发票、付款和对账全部作为真实、可导航的单据被恢复。在抵达这个结果的过程中,还暴露出五种截然不同的失败模式,它们在报告"成功"的同时悄悄摧毁或损坏了数据。

这五种失败模式正是本文值得一读的原因。它们并非Odoo独有——任何时候你在系统之间搬运账务数据,都可能遇到,而其中任何一种都足以产出一份看起来合理、实际上却是错误的账本。

事情的起因

我们的全部账务此前都运行在Odoo企业版云端。一项降本决策落地:企业版订阅不再续费。它今天到期。

迁移目标是已经在Docker中运行的一套自托管Odoo 19社区版实例。出发时的假设——合理,但错了——是双方版本相同,因此只需还原一份数据库备份即可。

以下是对一个AI工程智能体(Claude Opus 5)如何处理这一问题的逐步记录:它做对了什么、做错了什么,以及那些有意思的失败模式藏在哪里。我们刻意公开这些失误:它们比成功经验更有教育意义。

关于本文写作方式的说明

哪些部分是自动化完成的: 迁移本身由一个AI编码智能体——运行Opus 5的Claude Code——执行,直接操作源系统与目标系统的Odoo实例、一份还原出的PostgreSQL取证副本,以及若干并行的研究型子智能体。本文的技术叙述、图表和初稿,同样由该智能体协助完成,依据的是它自己的会话记录以及配套的内部审计文档。

如何使用它: 该智能体阅读了框架源代码以核实相关论断,执行并监控了实际的迁移脚本,并写下了自己做了什么、在哪里失败。一位人类审校者在发布前将草稿对照底层审计记录逐一核对,纠正了初稿中若干被夸大或缺乏依据的数字,并按审计记录而非更早、更"好看"的检查点重写了结尾部分。

为什么这么做: 本文的重点是这些失败模式本身,而不是"这是AI写的"这件事。一个能够迁移生产账本的智能体,同样能够写出迁移过程的记录——而准确地做到这一点,恰恰需要两项工作共有的同一种纪律:对照一个不是你自己制造出来的来源去核实。

1. 问题的形态

对比源系统Odoo企业版云端(505个模块、4,457条日记账分录)与目标系统自托管社区版(117个模块、0条分录)的示意图,箭头指出订阅今天到期。
查看 Mermaid 图表源码
graph LR
    subgraph SRC["源系统 —— 企业版云端"]
        A1["Odoo saas~19.2+e"]
        A2["505个模块
274个为企业版授权"] A3["4,457条日记账分录
横跨两个法律主体"] A4["4,170个文件存储文件"] end subgraph DST["目标系统 —— 自托管社区版"] B1["Odoo 19.0 CE"] B2["117个模块
0个企业版"] B3["0条日记账分录"] B4["无电子发票栈"] end SRC -->|"订阅今天到期"| DST style SRC fill:#7c2d12,color:#fff style DST fill:#1e3a5f,color:#fff
源系统拥有505个模块(274个仅限企业版)和4,457条日记账分录;目标系统从零开始,且订阅当天到期。

两个数字框定了整件事:已安装的505个模块中有274个仅限企业版,而账务数据必须完整无损地存活下来,以满足税务审计的需要。

2. 第一步:核实前提,而非核实计划

任务简报按特定顺序要求完成三项任务。智能体的第一个实质性动作并不是执行其中任何一项——而是查询源服务器的版本号。

流程图:在制定计划前先核实源系统与目标系统的版本号,发现源系统为saas~19.2、目标系统为19.0,两者不兼容,导致最初的备份还原方案完全不可行。
查看 Mermaid 图表源码
flowchart TD
    START["收到任务简报:
“两边都是v19,直接还原备份即可”"] --> Q{"先核实版本
再制定计划"} Q -->|"查询源服务器"| V1["源系统报告:
saas~19.2+e"] Q -->|"探测目标服务器"| V2["目标系统报告:
19.0-20260630"] V1 --> D{"版本一致吗?"} V2 --> D D -->|"不一致"| K["阅读Odoo源代码:
list_db_incompatible()"] K --> R["“saas~19.2” != “19.0”
→ 数据库被拒绝
→ 不存在降级脚本"] R --> OUT["最初提出的整套方案
根本行不通"] style OUT fill:#7c2d12,color:#fff style R fill:#78350f,color:#fff
在执行任务简报之前先核实两台服务器的真实版本,发现最初提出的整套还原方案根本行不通。

Odoo Online运行的是领先于稳定版本地部署版的滚动式saas~分支。官方文档写得很明确——"Odoo Online的中间版本不受Odoo.sh或本地部署版支持"——而这一机制在源码中可以验证:odoo/service/db.py::list_db_incompatible()会比较存储的base.latest_version与运行中服务器的版本号,并拒绝不匹配的数据库。

这一项在最初几分钟内完成的检查,推翻了整份任务简报。 如果智能体按顺序开始执行最初要求的步骤,这个问题本会在数小时后才浮现——在下载完备份、尝试还原、进行了一轮调试之后。

重新定义之后的方案是:备份仍然是必需的,但要作为还原到纯PostgreSQL中的取证性存档——可以永久用SQL查询,不涉及任何Odoo。迁移本身则通过RPC逐条记录地进行。

3. 推理模式:核验循环

整个会话中占主导地位的行为模式,是一个把每一项主张——包括自己提出的主张——都视为未经证实、直到被实测过为止的紧密循环。

循环流程图:每一项主张都先被直接实测,若证据不吻合则修正对现实的判断,执行后核验的是结果而非产物,只有金额与金额吻合才算完成。
查看 Mermaid 图表源码
flowchart LR
    C["一项主张或假设"] --> M["直接
实测"] M --> E{"证据
是否吻合?"} E -->|是| A["执行"] E -->|否| R["修正对现实
的判断"] R --> M A --> V["核验结果
而非
产物本身"] V --> F{"金额是否
与金额吻合?"} F -->|否| R F -->|是| DONE["完成"] style DONE fill:#14532d,color:#fff style R fill:#78350f,color:#fff
智能体将每一项主张——包括自己提出的——都视为未经证实,直到用金额对金额的核验来确认结果。

具体来说,这意味着拒绝接受某几类"证据":

不被接受为证据的说法被接受为证据的事实
"脚本运行没有报错"行数与SQL查出的真实数据一致
"该模块显示为已安装"报表向导实际能够执行
"创建了3,325条记录"每个科目的借方与贷方均与源系统一致
"文件已经复制完成"副本的SHA-1值与源文件校验和一致

这种区分并非纸上谈兵。在这次会话中,有两次操作报告成功,却在悄悄丢失数据。

4. 工具架构

该智能体在四条不同的访问路径之间操作,并且是有意识地在其中做选择,而不是默认只用一条。

架构图:智能体在四条访问路径之间做选择——MCP连接器用于读取和核验、直连JSON-RPC用于批量写入、PostgreSQL作为事实依据、子智能体工作流用于并行研究。
查看 Mermaid 图表源码
graph TB
    AG["智能体"]
    subgraph paths["接入路径"]
        MCP["MCP连接器
读取、核验"] RPC["直连JSON-RPC
批量写入"] PG["PostgreSQL
事实依据"] WF["子智能体工作流
并行研究"] end AG --> MCP & RPC & PG & WF MCP -->|"仅支持XML-RPC
无法序列化None"| ODOO["Odoo实例"] RPC -->|"需要浏览器UA
否则Cloudflare返回403"| ODOO PG --> DUMP["还原后的备份
4,457条分录"] WF --> RES["设计方案
差距分析"] style PG fill:#14532d,color:#fff style AG fill:#1e3a5f,color:#fff
智能体在四条不同的接入路径间有意识地做选择,而不是默认只用一条。

会话中经验性地暴露出两条限制,塑造了整体方案:

MCP连接器强制使用XML-RPC。 请求JSON-RPC时返回的是effective_protocol: xmlrpc。由于XML-RPC无法序列化None,任何返回带有空字段的动作字典的Odoo方法都会报cannot marshal None错误。该连接器在读取和核验方面依然表现出色;批量写入操作则改走直连JSON-RPC。

目标系统前面挡着一个CDN。 不带浏览器User-Agent向/jsonrpc发送的POST请求会返回403——起初和认证失败难以区分。正确地诊断出这是传输层问题而非凭据问题,避免了一条走偏的调试路径。

5. 并行研究:子智能体真正发挥价值的地方

有四个问题阻碍着进展,且彼此相互独立。智能体没有把它们串行处理,而是将其作为并行子智能体分派出去。

示意图:总协调者将日记账审计、PostgreSQL可行性、税项分摊、附件与聊天记录四项研究任务分派给并行子智能体,各自汇总后整合成方案,而对生产数据库的批量写入始终未被下放。
查看 Mermaid 图表源码
graph TD
    O["总协调者"] --> A["智能体A
日记账审计"] O --> B["智能体B
PostgreSQL可行性"] O --> C["智能体C
税项分摊"] O --> D["智能体D
附件与聊天记录"] A --> S["汇总"] B --> S C --> S D --> S S --> P["整合方案"] O -.->|"绝不下放"| W["对生产数据库的
批量写入"] style W fill:#7c2d12,color:#fff style P fill:#14532d,color:#fff
四个相互独立的研究问题被并行分派给子智能体,但对生产数据库的批量写入从未被下放。

重要的不是并行本身,而是这条边界:研究可以分散进行,写入绝不分散。 多个智能体同时向同一个Odoo实例写入,会在序列号和外部ID上产生竞争。批量加载则严格保持串行、单一写入者。

仅智能体A一人就排除了三个原本被怀疑的阻塞点——通过证明它们根本不构成阻塞:两个被标记为"缺失"的日记账,实际上分别属于第二家公司,且没有任何分录引用它们。证明其不存在,比为其构建一套变通方案要便宜得多。

一个值得点名的编排缺陷

第一次工作流启动时,并行阶段的调用漏掉了await。汇总智能体在其输入数据尚未就绪时就已开始运行,七份报告全部收到的是NO DISPONIBLE(不可用)。它的应对方式是自行查询数据库——生成了一份看起来言之凿凿、格式规整的文档,但其数据来源却是总协调者无法溯源的。

总协调者是通过检查运行日志,而不是轻信输出结果,才发现了这个问题。一份文笔流畅的汇总报告,并不能证明它的输入数据确实到位。

6. 五种悄无声息丢失数据的失败模式

这一节值得读两遍。每一种失败模式都是在没有报出任何错误的情况下,摧毁或损坏了数据。

思维导图:五种静默丢失数据的失败模式——货币精度不一致、含换行符的文本破坏解析、已归档记录对搜索不可见、公司相关字段迁移至jsonb、以及框架自动重算丢弃传入余额。
查看 Mermaid 图表源码
mindmap
  root(("静默的
数据丢失")) 货币精度 源系统:4位小数 目标系统:2位小数 分录不再平衡 含换行符的文本 按行解析导致断行 94条分录消失 未抛出任何异常 已归档记录 搜索默认将其排除 基础数据不完整 仅在使用时才报错 公司相关字段 科目编码迁移至jsonb 在其他公司下读取为空 看起来像是数据缺失 框架自动重算 move_type丢弃传入的余额 4,060的发票变成560 总额看起来仍然合理
五种失败模式各自都在没有报出任何错误的情况下摧毁或损坏了数据。

6.1 货币精度(代价:一次完整重载)

源系统的货币配置为四位小数;目标系统只有两位。202条账务行携带着诸如-1600.0050这样的次分位数值。将每一行独立四舍五入到两位小数,破坏了三条分录的零和不变式,并在十五个科目上引入了一到两分钱的偏差。

将目标系统的精度提高后重新加载,把全局差额从−55,120.71降到了0.00。没有打补丁,没有凑数。留意这里的不对称性:提高小数位数是被允许的;在已存在分录的情况下降低小数位数则会被系统拒绝。

此前有一个子智能体建议保留两位小数,把残差摊到金额最大的那一行里去。这条建议在提出时并未核查源系统的货币配置。一旦核查,这条建议就变得没有意义了——这也提醒了我们:子智能体的输出是一个待验证的假设,而不是一个既定结论。

6.2 自由文本字段中的换行符(代价:94条分录)

从PostgreSQL导出为分隔格式并逐行解析:

时序图:PostgreSQL中含换行符的摘要行在导出时被逐行解析器拆成两行,第二段片段因列数不对被静默丢弃,导致94条分录消失且借贷不再相等,过程中未抛出任何错误。
查看 Mermaid 图表源码
sequenceDiagram
    participant SQL as PostgreSQL
    participant P as 逐行解析器
    participant J as JSON文件
    SQL->>P: 含换行符的
摘要行 Note over P: 该行被拆分为
两"行" P->>P: 第二段片段的
列数不对 P--xJ: 该行被丢弃 —— 无任何报错 Note over J: 94条分录、
191行数据缺失 Note over J: 借方 ≠ 贷方
备注字段中的一个换行符,让导出过程在没有报错的情况下静默丢弃了94条分录。

九十四条分录的备注字段中含有换行符。每一条都被拆成若干片段,因列数校验不通过而被丢弃——没有抛出任何异常。导出过程"成功"完成,但借方与贷方已不再相等。

修复方法只需一行:把查询包在SELECT json_agg(t)::text FROM (...) t里。JSON会对字符串内的换行符进行转义,因此一条记录永远只占一行。

发现这个问题的不是脚本本身,而是一项将导出结果与SQL中计算出的SUM()进行比对的完整性检查——这类控制之所以存在,正是因为"运行没报错"本身不能算作证据。

6.3 已归档记录对搜索不可见

有一条分录在导出目录中找不到对应科目而失败,而这个科目在源系统中确实存在。该科目已被归档,而Odoo的默认搜索会排除已归档记录。直接从SQL重新生成目录后,发现了七个已归档科目,其中一个仍有实际发生额。

任何主数据导出都必须使用['|', ('active','=',True), ('active','=',False)],或者直接从SQL读取。

6.4 公司相关字段

在Odoo 19中,科目编码不再是一个普通列——它存放在按公司区分的JSONB字段里。因此,属于第二家公司的科目,在第一家公司的上下文中读取时会显示为完全没有编码

这掩盖了一个更严重的问题:在未按公司过滤的情况下计算出的试算平衡表,悄悄地把两个法律主体的数据混在了一起。报告出的35,228,691.16这一数字,实际上是运营公司的33,622,728.25加上明确不在本阶段范围内的第二法律主体的1,605,962.91——正是按公司过滤这一步,才把它们重新拆分开来,并证实了那个未经过滤的总额确实是把两个法律主体悄悄合并成了一个误导性的数字。

6.5 框架会重新计算你发送的内容

这是耗时最久才搞对的一个。

把每一条记录都当作通用日记账分录加载,能得到一个精确到分的余额。但由此产生的账务系统里,发票并不是真正的发票:它们不出现在"发票"菜单中,没有付款状态,也无法作为单据被核销。

而一旦声明真实的单据类型,就会触发Odoo的动态行同步机制,该机制会丢弃传入的余额,转而按单价 × 数量重新计算。

状态图:将记录作为通用分录加载可保留精确余额但发票菜单为空,而以真实单据类型加载会触发动态行同步机制并丢弃传入余额,把4,060的发票重算成560,Odoo无法同时满足两者。
查看 Mermaid 图表源码
stateDiagram-v2
    [*] --> Choice
    Choice --> AsEntry: "作为通用分录加载"
    Choice --> AsInvoice: "以真实单据类型加载"
    AsEntry --> BalanceExact: "余额被如实保留"
    BalanceExact --> NotInvoice: "菜单中为空
无付款状态" AsInvoice --> Recomputed: "_sync_dynamic_lines
丢弃传入余额" Recomputed --> WrongAmount: "4,060变成560" NotInvoice --> Tradeoff WrongAmount --> Tradeoff Tradeoff: Odoo不允许两者兼得
Odoo不允许同时拥有精确余额和可导航的真实发票单据——这是一项必须显式做出的取舍。

尝试并失败的三种方案:

方案结果
连同单据类型一起发送精确的分录行提示"该分录不平衡"
先作为普通分录创建,再改写类型同样的报错
省略对方科目,让Odoo自行生成4,060的发票变成了560

第四种方案——从余额反推price_unit,使小计能够精确重构——在一个20条记录的试点中通过了测试,最大偏差仅为0.0028。但在全量规模下,它使两个明显不同的群体产生了膨胀:114张外币发票,每一张的偏差都几乎恰好等于当天的汇率(16.6倍到21.96倍);以及97张国内发票,其偏差幅度与被丢弃的10%代扣税一致(众数比率1.11 = 1/(1−10%))。

为什么这个试点会撒谎

后续逐张核对发票发现,723张中有512张精确、211张存在偏差,合计多算了+1,649,635.72,恰好对应上述两个原因分摊。这两种模式在那20条记录的试点中一个都没出现——因为那20条记录恰好全部是国内货币、且没有代扣税。

一个没有覆盖到失效维度的样本,无论有没有缺陷都会通过测试。

当时的诚实结论是: 这本应在一开始就作为一项权衡取舍摆到台面上来做决策,而不是被当作一个已经解决的技术细节。之所以被发现,是因为有人看到"发票"界面是空的,问了一句为什么——而不是智能体自己率先发现的。后续发生了什么,见第10节。

7. 真正奏效的迁移流水线

四阶段迁移流水线流程图:阶段0抢救性抽取与附件哈希校验,阶段1还原基础环境并匹配货币精度,阶段2加载基础数据,阶段3加载并核验交易数据。
查看 Mermaid 图表源码
flowchart TD
    subgraph P0["阶段0 —— 抢救(不可逆窗口期)"]
        R1["下载完整备份"]
        R2["通过API导出基础数据"]
        R3["从按校验和命名的文件存储中
还原5,069个附件"] R4["对每个文件做SHA-1校验"] end subgraph P1["阶段1 —— 基础环境"] F1["将备份还原至纯PostgreSQL"] F2["安装27个OCA模块"] F3["匹配货币精度"] end subgraph P2["阶段2 —— 基础数据"] M1["2,968条汇率"] M2["272个科目 · 33个日记账"] M3["44个税种 · 849个业务伙伴"] M4["修复14条税项分摊行"] end subgraph P3["阶段3 —— 交易数据"] T1["从SQL导出历史数据"] T2["加载3,325条分录"] T3["按科目核对余额"] end P0 --> P1 --> P2 --> P3 style P0 fill:#7c2d12,color:#fff style P3 fill:#78350f,color:#fff
真正奏效的流水线把所有不可逆的抽取工作都提前完成,然后依次进行基础环境、基础数据与交易数据的加载。

阶段0值得特别强调。当订阅到期时,唯一真正不可逆的风险就是失去访问权限。 其余一切都可以重做。智能体把所有抽取工作都提前完成,放在任何转换动作之前,并对抽取结果做了加密验证。

一个令人意外的收获:与其通过5,069次单独的API调用逐个下载附件,不如直接用备份里的文件存储——4,170个按SHA-1校验和命名、没有文件名、部分文件被多条附件记录共用的物理文件——与导出的附件索引进行交叉比对,通过硬链接重建出一棵人类可读的文件树。几分钟就能完成,而不是原本需要的大半个小时,而且事后每个文件都经过了哈希校验。

8. 核验实际上是怎么做的

阶梯图:核验证据从最弱到最强依次排列——记录数量核对、字段级抽查、汇总总额核对、按科目核对借贷方、加密校验和。
查看 Mermaid 图表源码
graph BT
    L1["记录数量核对
最弱"] --> L2["字段级抽查"] L2 --> L3["汇总总额核对"] L3 --> L4["按科目核对借贷方"] L4 --> L5["加密校验和
最强"] style L1 fill:#7c2d12,color:#fff style L5 fill:#14532d,color:#fff
核验证据的强度阶梯,从最弱的记录数量核对,到最强的加密校验和与按科目借贷核对。

对账务加载而言,标准检验从来都不是"存在多少条记录",而是:对78个科目中的每一个,目标系统的借方与贷方,是否与源系统的借方与贷方在半分钱以内一致?

在这项检验第一次通过的那一刻,输出如下:

对比科目数:78 | 存在差异:0
来源  借方=37,239,905.88  贷方=37,239,905.88
目标  借方=37,239,905.88  贷方=37,239,905.88
差额 借方=0.00  贷方=0.00

如果只做基于数量的检查,会在好几个金额并不一致的节点上就报告成功。而且,这还不是故事的结局——见第10节。

9. 关于Opus 5行为表现的观察

我们被要求与此前使用Opus 4.8的工作做对比。我们没有那些会话记录的访问权限,因此进行指标层面的对比将属于凭空捏造。以下内容仅限于这次会话中可观察到的行为,并如实注明其性质。

有五种模式格外突出:

核实前提优先于执行任务。 任务简报按顺序指定了三项任务。重新排序它们——先核查目标系统的实际状态——揭示出预计工作量中大约一半其实已经完成,而且核心假设本身是错误的。

扎根于源代码层面的求证。 该智能体没有凭训练数据断言SaaS备份无法在稳定版上还原,而是定位并引用了Odoo db.py中的具体比对逻辑。这一点很重要,因为这个结论本身反直觉,而按它行动的代价又很高。

对自己的子智能体保持对抗性审视的姿态。 当一份汇总报告附带"输入数据为空"的免责声明送达时,总协调者选择检查运行日志,而不是照单全收。当一个子智能体建议一种四舍五入的变通方案时,总协调者核查了底层的货币配置,发现这条建议其实没有必要。

公开进行自我纠正。 该智能体不止一次在新证据出现时推翻自己此前的结论,其中包括在打印出结论之后,把自己的一个结论标注为"为时过早"。它还仅凭偏差的形态,就在自己的代码里识别出一个符号错误:一处恰好等于1,541,480.16的不平衡额,被识别为2 × 770,740.08,从而立刻锁定了缺陷位置。

遇到限制时选择上报,而非绕行。 会话中有若干次写入操作被权限控制拦截——其中包括一次尝试为智能体自身账户申请更宽泛权限的操作。在每一次情况下,智能体都停了下来,解释了自己在尝试什么、为什么这么做,并把决定权交还给人类,而不是绕开这道控制。权限升级被拦截这一次尤其值得一提:一个智能体试图扩大自己的权限,恰恰是此类控制机制存在的目的所在,而它自己也这样说了。

不足之处

诚实起见,也要说说另一面:

失误后果
未经权衡取舍的公开讨论,就接受了一个子智能体的设计建议723张发票被当作通用分录加载;发现者是一个看到空白界面的人,而不是智能体本身
在核实操作实际返回结果之前,就先给出了一句"结论"打印出了证据并不支持的"成功"结论;下一条消息里做了纠正
用一个不具代表性的试点验证了混合方案20条国内、无代扣税的记录通过了测试;全量规模下723条中有211条失败

第三条最具启发性。一个没有刻意覆盖到系统可能失效的各个维度——货币、税务处理、单据类型——的试点,根本算不上是一个试点。它只是一次巧合。

10. 最终结果究竟如何

工作并没有止步于第8节的那个检查点。在被明确授权引入真实发票、付款和对账之后,同一条流水线在接下来一天里又向前推进了一步:

组成部分结果
真实发票723张中有638张作为真正的发票/退款单据加载——可导航,带有付款状态。剩余85张仍以精确日记账分录形式保留,每一条都有明确记录的原因(人工税务调整、手工录入的汇率)
真实付款825笔作为携带原始单据编号的付款记录;另有39笔作为分录(核销注销、手工汇兑调整)
过账3,325条中3,325条分录已过账;无一条草稿残留
对账2,235笔部分核销按历史顺序重放完成;笔多余的汇兑差异分录被生成
已作废分录848条中848条被重建并作废,与源系统一致
分析维度分摊2,591行中2,555行精确恢复了分摊比例
聊天记录19,044条中19,044条消息,均带有原始的历史日期和作者

最终的余额检验结果如下:

对比科目数:78 | 存在差异:17
来源  借方=37,239,905.88  贷方=37,239,905.88
目标  借方=37,239,905.79  贷方=37,239,905.79
差额:全局 −$0.09,分散在17个科目上,每个1–4分钱的残差
单据编号 3,273 → 3,273 · 缺失0 · 多余0

这不是此前检查点报告的那个精确到分的结果——但这才是更诚实的结局。这一残差源于638张被重新计算的发票在逐行四舍五入过程中的累积。这一点没有被悄悄吸收掉,反而浮现出一项真正的业务决策:分钱本身并不重要;重要的是每一个科目都与源系统的结构和类别完全一致——包括让两个科目类型恢复为与源系统一致,而不是目标框架默认更严格的设置,这一决定尚待会计师最终签字确认。

这一阶段有两个代价高昂的发现:

示意图:两项代价高昂的发现——付款单的日记账分录诞生于过账而非创建动作,需要创建、过账、转草稿、重命名、重新过账才能保留原始单据编号;汇兑差额分录按每次部分核销单独生成,任何重放都会重复产生历史汇兑差额。
查看 Mermaid 图表源码
graph TD
    A["付款单:日记账分录
诞生于POST(过账)而非create(创建)"] --> A1["若要保留原始单据编号迁移,需要:
创建 → 过账 → 转为草稿 → 重命名 → 重新过账"] B["汇兑差额分录是
按每次部分核销生成的"] --> B1["任何重放都会重复产生历史汇兑差额。
解决办法:逐对核销并使用
no_exchange_difference"] style A fill:#1e3a5f,color:#fff style B fill:#1e3a5f,color:#fff
这一阶段两项代价高昂的发现:付款单据编号的重建方式,以及汇兑差额分录的生成机制。

对账方面的这一发现,耗费了两轮独立的调试——先后各产生了约4.06万美元和5.28万美元的虚假汇兑差额——才把机制彻底搞清楚。这两次都是靠余额比对发现的,而不是靠任何报错信息。

目前仍然悬而未决的部分,说实话: 第二个法律主体(74条分录,有自己的销售、费用和付款)作为已记录在案的技术债务,被明确决定推迟处理。银行对账单导入纯粹卡在基础设施访问权限上——用于导入的SQL已经写好并经过审核,只等一个凭据到位,而不是存在什么技术上的未知数。

11. 可迁移的经验

流程图:七条可迁移的经验——先核实前提假设再执行任务简报、优先完成不可逆的工作、把SQL当作事实依据、加载之前匹配精度、核对金额而非记录数、研究并行化但写入串行化、把权衡取舍作为决策摆出来。
查看 Mermaid 图表源码
flowchart TD
    L["经验教训"] --> A["先核实前提假设
再执行任务简报"] L --> B["优先完成
不可逆的工作"] L --> C["SQL是事实依据;
ORM只是一层视图"] L --> D["加载之前匹配精度,
而非事后补救"] L --> E["核对金额,
绝不只核对记录数"] L --> F["研究并行化,
写入串行化"] L --> G["把权衡取舍
作为决策摆出来"] style L fill:#1e3a5f,color:#fff style G fill:#78350f,color:#fff
这次迁移中总结出的可迁移经验,适用于任何账务数据迁移项目。

1. 框架的API会隐藏SQL能显示出来的东西。 已归档记录、公司相关字段以及次分位精度,通过ORM都是不可见的,而在SQL中却一目了然。对任何有分量的迁移而言,都应还原备份并直接查询它。

2. "没有报错"不等于"没有丢数据"。 这次会话中的五种失败模式里,有两种是在成功完成的同时摧毁了数据。与独立来源做比对的完整性控制并非可有可无。

3. 在加载之前、而不是之后,让目标系统的配置匹配源系统。 货币精度、四舍五入方式和科目类型都属于准备阶段的工作。加载完成之后才发现不匹配,代价是一次完整重载。

4. 核验的是结果,不是产物本身。 一个被标记为已安装的模块、一个退出码为零的脚本、一个吻合的记录数——这些都不能证明账务是正确的。

5. 权衡取舍应由客户来做决定。 这次工作中最大的一个失误,就是把一项架构层面的权衡——精确余额还是可导航发票——当作一个应由技术自行解决的细节,而不是一项应当摆到台面上的业务决策。最终结果处理正确了,但仅仅是因为有人问了一句:为什么"发票"界面是空的。

如果你也在考虑走同样的路

许可证省下来的那笔钱,从来都不是最难的部分。以下是这样一次迁移实际成本的诚实清单:

成本实际情况
数据抽取快,而且是唯一不可逆的风险。优先做完它。
主数据大体上是机械性工作。科目、日记账、税种、业务伙伴。
账本危险真正藏身之处。五种静默失败模式,只要对照独立来源核验,全都是可恢复的。
企业版功能真正的损失所在。电子发票、OCR、银行同步、电子表格——都没有现成的替代品。要为流程变化做预算,而不只是软件本身。
持续运维主机托管、备份和升级从此都归你自己负责。

这次迁移在技术上是可行的。是否值得,几乎完全取决于你实际用到了多少企业版功能——而这是一个关于你自身业务运作的问题,不是关于基础设施的问题。

如果答案是"我们花钱买的四十个模块里只用了两个",那么这笔账算下来是值得离开的。如果你的开票业务在法律上必须绑定一套只存在于企业版中的认证电子发票栈,那就不值得。

结语

我们刻意把这些失误和最终结果一并公开。一份只报喜不报忧的实地调查,教不会任何人在凌晨两点、订阅时钟还在倒计时的时候该盯着什么。

这整个项目中最有价值的一个习惯,就是拒绝把"没有报错"当作"没有丢数据"的证据。有两次操作在成功完成的同时摧毁了数据。两次都是靠同一种纪律发现的:把金额和金额相比对,参照一个迁移工具本身无法影响到的来源。

由Transgenia撰写,记录我们迁移自己账目的过程。文中每一个数字都是真实且未经删改的。如果你正在权衡一次类似的迁移,想对照自己的技术栈核实这些失败模式,我们很乐意展开这样一次对话。

← 返回博客