编者按。 这是我们自己的账目。在向任何人推荐这条路径之前,我们先迁移了自己的账本——文中每一个数字都是真实且未经删改的。本文由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. 问题的形态
查看 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个仅限企业版,而账务数据必须完整无损地存活下来,以满足税务审计的需要。
2. 第一步:核实前提,而非核实计划
任务简报按特定顺序要求完成三项任务。智能体的第一个实质性动作并不是执行其中任何一项——而是查询源服务器的版本号。
查看 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. 工具架构
该智能体在四条不同的访问路径之间操作,并且是有意识地在其中做选择,而不是默认只用一条。
查看 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. 并行研究:子智能体真正发挥价值的地方
有四个问题阻碍着进展,且彼此相互独立。智能体没有把它们串行处理,而是将其作为并行子智能体分派出去。
查看 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. 五种悄无声息丢失数据的失败模式
这一节值得读两遍。每一种失败模式都是在没有报出任何错误的情况下,摧毁或损坏了数据。
查看 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导出为分隔格式并逐行解析:
查看 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: 借方 ≠ 贷方
九十四条分录的备注字段中含有换行符。每一条都被拆成若干片段,因列数校验不通过而被丢弃——没有抛出任何异常。导出过程"成功"完成,但借方与贷方已不再相等。
修复方法只需一行:把查询包在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的动态行同步机制,该机制会丢弃传入的余额,转而按单价 × 数量重新计算。
查看 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自行生成 | 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. 真正奏效的迁移流水线
查看 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. 可迁移的经验
查看 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撰写,记录我们迁移自己账目的过程。文中每一个数字都是真实且未经删改的。如果你正在权衡一次类似的迁移,想对照自己的技术栈核实这些失败模式,我们很乐意展开这样一次对话。