POS 最让人紧张的场景之一,不是收款失败,而是“会不会记重单”。
比如这些现场问题:
- 断网后补传时,店员连点几次同步;
- 同一订单在两台设备上都试过提交;
- 浏览器卡住后刷新,前台又把那张单重新送了一次;
- 支付完成后,又收到一份迟到的旧 payload。
很多人以为 Odoo 之所以没那么容易写重,是因为前端刚好没重复提交。其实恰恰相反,源码默认就假设前端和网络都可能“不干净”,所以它在后端做了一整套“先认人,再决定是更新、创建还是忽略”的幂等保护。
先说结论:Odoo POS 处理重复同步的关键,不是 pos_reference 好不好看,而是 uuid 这一层的唯一身份。 pos_reference 更像票号;真正的“这是不是同一张前端订单”判断,主要靠 uuid。
第一层防线:uuid 不是装饰字段,而是唯一键
在 pos.order、pos.order.line、pos.payment 上,源码都定义了 uuid 字段,并加了唯一约束。
这件事的含义非常直接:
- 前端生成的数据不是只靠数据库自增 id 认身份;
- 即使网络重试、设备重发,后端也能先用
uuid识别“你是不是之前那张单”。
所以如果你想理解 Odoo POS 的幂等思路,第一句就该记住:
数据库里的主键是服务器内部身份,
uuid才是前后端协作时的跨端身份。
这也是为什么升级脚本里甚至专门有 post-deduplicate-uuids.py。Odoo 很清楚:只要 uuid 混乱,整条同步链的防重能力就会变差。
第二层防线:sync_from_ui() 不是盲目 create,而是先找旧单
sync_from_ui() 的主线很清楚:
- 逐张接收前端订单;
- 先调用
_get_open_order(order),按uuid查现有订单; - 如果找到且状态还是
draft,就走更新; - 如果找不到,才创建;
- 如果找到了,但状态已经不是
draft,通常忽略这次重复同步。
也就是说,Odoo 后端不是“收到一张单就插一张单”,而是:
- 同一个
uuid+ 仍可修改 → 更新; - 同一个
uuid+ 已经落定 → 忽略重复; - 完全陌生的
uuid→ 新建。
这就是它在脏网络环境下尽量保持幂等的核心骨架。
为什么只有 draft 单允许被覆写
源码对 draft 非常宽容,对非 draft 很克制。
原因很现实:POS 前台在订单还没正式完成时,收银员可能不断修改:
- 增减商品;
- 改客户;
- 改付款;
- 调整备注;
- 重传未完成订单。
所以 draft 单是可以被后续同步覆盖的。
但一旦订单已经 paid、done 或 invoiced,Odoo 就开始把它当成业务事实,而不是前端草稿。此时重复同步再来,系统宁可忽略,也不愿意把已落定订单再按旧 payload 改写一遍。
_process_order() 里还有一层“先保存行和付款,再更新主单”
在更新已有订单时,源码有个容易被忽略的细节:
- 先处理
lines和payment_ids; - 再写其他字段;
- 并把
uuid、access_token之类保护起来,不让重复覆盖乱改。
这样做不是啰嗦,而是在防止订单状态变化后,再去改子表时出现不一致。
尤其是支付相关字段,如果先把主单状态写成 paid,再去碰 payment lines,很多边界会变得危险。
relations_uuid_mapping 在解决什么问题
很多人以为只要订单本身有 uuid 就够了。其实 POS 还有一个更深的难点:
前端一次同步时,很多关联对象也可能还是“前端临时身份”。
所以 _process_order() 里会处理 relations_uuid_mapping:
- 先用各模型的
uuid找到真正记录; - 再把 many2one / one2many / many2many 关系补上。
这代表 Odoo 的目标不只是“别重单”,而是“让前后端在异步创建后还能重新对齐关联关系”。
换句话说,幂等不只是订单级别,还是关系级别。
pos_reference 到底是干什么的
pos_reference 在源码里叫 Receipt Number,更像门店和财务看得懂的票号。
它很重要,但它的职责和 uuid 不一样:
uuid:系统防重、跨端识别、同步幂等;pos_reference:业务展示、票据追踪、人工沟通。
所以排查重复单时,不要只盯着 pos_reference。两张异常记录看起来票号像,不代表它们一定是同一单;反过来,同一单的同步冲突,关键也往往要回到 uuid。
为什么已落定订单的重复同步会被“吃掉”
sync_from_ui() 中有段注释很诚实:理论上不该出现,但实践中会发生,例如某些 tip later 场景。
所以源码对“已存在且非 draft 的订单”采用保守策略:
- 保留必要的准备变更;
- 返回已有订单 id;
- 日志里标记这次 sync 被忽略。
这不是丢数据,而是在明确表达:
这份 payload 太晚了,订单事实已经成立,不能再用它改写。
最容易误解的三件事
误区一:POS 防重主要靠前端别重复提交
不对。后端才是真正的防线,uuid 和状态判断才是核心。
误区二:pos_reference 就是唯一身份
不对。它更像收据号;真正的同步身份主要是 uuid。
误区三:重复同步一定会报错
也不对。很多情况下,系统会选择忽略迟到 payload,而不是把前台体验变成显式报错。
实战排错顺序
遇到“怀疑写重单 / 同步重复 / 订单被旧数据覆盖”的问题,建议按这个顺序查:
- 先比对异常订单的
uuid是否相同; - 再看对应订单在第一次同步时的状态,是
draft还是已paid/done; - 看日志里
sync_from_ui()是走了 create、update,还是 ignored; - 检查订单行、支付行是否也各自带有稳定
uuid; - 如果关系丢了,回查
relations_uuid_mapping是否正确传入; - 最后再看
pos_reference是否只是展示层误导,而不是真重复。
最后的结论
Odoo POS 在防重这件事上,最值得肯定的不是“绝不出错”,而是它的设计思路足够现实:
- 接受网络会重试;
- 接受前端会重复送;
- 接受多设备会并发;
- 但尽量用
uuid + 状态边界 + 关系补链把重复写入压到最低。
所以别把 POS 的幂等理解成“运气好,大家刚好没重复点”。
它真正依赖的,是后端先识别这是不是同一张单,再决定该更新、创建,还是礼貌地忽略。
DISCUSSION
评论区