活动业务流

Odoo 展位销售为什么不是“卖一个 Booth 产品”就完事:预留、确认、抢占冲突与付款落定链路讲透

Odoo 的 Event Booth Sale 看起来像是在销售订单里卖一个服务产品,但源码实际上把“想要某个展位”“确认拿到某个展位”“付款后正式落定”拆成了多层状态。本文沿着 sale.order.line、event.booth.registration、sale.order 和 account.move 把这条链路拆开讲清。

其他 销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 8 阅读

先说结论

Odoo 的 Event Booth Sale 不是“销售一条 booth 商品线,然后订单确认就结束”。

/home/ubuntu/odoo-temp/addons/event_booth_sale 里的实现看,系统把展位销售拆成了四个层次:

  1. 产品层:某个商品是否被定义为 service_tracking = event_booth
  2. 意向层:销售订单行上先挂 event_booth_pending_ids
  3. 确认层event.booth.registration 把订单行和展位绑起来,并在确认时处理抢占冲突
  4. 落定层:发票付款后,展位再被标记为 is_paid

所以它不是“卖一个 Booth 产品”,而是:

先登记想要哪个展位,再确认这个展位还能不能拿到,最后在付款后把占位关系彻底落定。


第一层:为什么展位必须是特殊 tracking 的服务产品

product.templateevent_booth_sale 里扩展了 service_tracking

  • 新增 event_booth

而且一旦选择这个 tracking:

  • invoice_policy 会被改成 order
  • service_tracking 还会被加入 blacklist 控制
  • 如果产品或变体已经被某个 event.booth.category 使用,就不允许你再把它改回别的 tracking

这背后的业务语义很清楚:

  • 展位不是普通库存品
  • 也不是标准项目服务
  • 它是一种“会映射到具体 Booth 资源”的销售对象

因此 Odoo 先在产品层把它单独建模,避免你把同一个产品半路改成别的服务类型,把整个展位分类体系弄坏。


第二层:为什么销售订单行先挂的是 pending booth,而不是最终 booth

sale.order.line 里最关键的字段有三组:

  • event_booth_pending_ids
  • event_booth_registration_ids
  • event_booth_ids

这三个字段分别代表三件不同的事:

1. pending booth

销售人员或客户当前选中的意向展位。

2. registration

订单行和展位之间的一条“候选/确认关系”记录。

3. confirmed booth

最终已经落到该订单行上的 booth。

_inverse_event_booth_pending_ids() 这一段很关键:

  • 用户改 pending booths
  • 系统不会直接把展位写成最终归属
  • 而是创建 / 删除 event.booth.registration

也就是说,pending 只是选项,registration 才是待确认关系,confirmed booth 才是结果。

这是一个很好的业务拆分,因为展位天然存在竞争:多个客户可能想要同一个 booth。直接把选择写成最终结果,会让并发冲突变得很难处理。


第三层:为什么订单确认前还要再校验一次展位可用性

sale.order.action_confirm() 在 event booth 场景下并没有完全相信前面的界面选择。

它会先做两轮检查:

1. 有没有遗漏 booth 配置

如果某条 service_tracking == 'event_booth' 的订单行没有 event_booth_pending_ids,直接抛错,要求先配好展位。

2. 再调用 _update_event_booths()

_update_event_booths() 里又会继续检查:

  • 当前 pending booths 里是否有 not booth.is_available 的展位
  • 如果有,直接抛 ValidationError

这说明 Odoo 很清楚前台选中和真正确认之间存在时间差。

你在草稿里选中 booth,并不代表到订单确认那一刻,它还没被别人抢走。

所以系统在确认前重新核一次可用性,避免“表单里看起来能选,落单时却已经不属于你”的脏状态进入数据库。


第四层:registration 的真正作用,是把“竞争预留”变成“胜者确认”

event.booth.registration 这个模型的注释已经把意图说得很直白:

允许多个 partner 同时为同一个 booth 建 registration;当其中一个付清或确认后,其他关联 registration 会被删除。

它有几个非常关键的设计:

  • unique(sale_order_line_id, event_booth_id):同一订单行不能对同一展位重复登记
  • partner_id 默认来自订单客户
  • contact_name / email / phone 会默认从 partner 带出,但允许存成 registration 上的快照

这说明 registration 不是一个轻飘飘的中间表,而是:

  • 记录谁在竞争这个 booth
  • 记录业务联系信息
  • 作为后续冲突消解和 booth 确认的承载对象

第五层:为什么一个客户确认后,别人的订单会被取消

event.booth.registration.action_confirm() 会做两件事:

  1. registration.event_booth_id.action_confirm(values),把当前 registration 的关键信息写入 booth
  2. _cancel_pending_registrations(),把同 booth 的其他 registration 全部处理掉

_cancel_pending_registrations() 的做法非常强硬:

  • 找出相同 event_booth_id、但不是当前这批 registration 的其他登记
  • 给这些订单发一条 message,告诉他们相应 booth 已被别人保留
  • 对这些订单执行 _action_cancel()
  • 最后把其他 registration unlink 掉

也就是说,在 Odoo 的设计里:

同一个 booth 的竞争不是“大家都先保留,最后人工解决”,而是“谁先走到确认环节,谁赢;其他单自动失效”。

这是一种很典型的稀缺资源销售策略。


第六层:为什么付款还要再走一步 is_paid

很多人会问:

既然订单确认时 booth 都已经确认下来了,为什么付款时还要再更新一次?

答案在 account_move._invoice_paid_hook()

  • 发票付款后,系统会沿着 line_ids.sale_line_ids 找回销售订单行
  • 再执行 _update_event_booths(set_paid=True)
  • 如果订单行上已经有 event_booth_ids,就调用 action_set_paid() 把 booth 标成已付款

这说明 Odoo 在这里故意区分了两层状态:

1. Booth 被哪个订单拿到

这是确认层状态。

2. Booth 对应的销售是否已经真正收款

这是财务落定层状态。

这种拆法非常合理,因为活动主办方现实里往往既关心“这个 booth 有没有被占”,也关心“占了的人到底付没付款”。


第七层:价格为什么不是简单取产品单价

sale.order.line._get_display_price() 在 booth 场景下也被专门改了。

如果订单行已经选了 event_booth_pending_ids

  • 有些情况取 booth.booth_category_id.price_reduce
  • 有些情况直接累加 booth 自身 price
  • 最后再换算成订单行币种

这说明展位销售的价格口径不是“产品模板固定单价”那么死,而是允许:

  • 按 booth category 定价
  • 按具体展位价格汇总
  • 结合 pricelist 上下文重新算

所以如果现场发现 booth line 的显示金额和产品卡上不一样,先别急着说是价格错了,很可能是因为实际计价逻辑以 booth 资源本身为准。


第八层:实施和排错时最该看哪几处

1. 产品 tracking 对不对

如果 booth 对应产品没设成 event_booth,后面整条链都不会成立。

2. 订单行是否真的有 pending booths

没有的话,确认订单前就会被挡住。

3. 展位在确认那一刻是否 still available

不是选中时可用就够,确认时还要再次可用。

4. 竞争订单是否被系统自动取消

如果客户说“我的订单怎么突然取消了”,先查是不是同 booth 被别的 registration 抢先确认。

5. 发票付款后 booth 是否被标 paid

如果 booth 已确认但财务仍显示未付款,就从 _invoice_paid_hook() 这条链往回查。


最后一句

Event Booth Sale 最值得理解的,不是“它能卖展位”,而是它把展位当成了一种稀缺可争抢资源

  • 先意向选择
  • 再登记竞争
  • 确认时决出归属
  • 付款后标记财务落定

所以这套模块真正卖的不是“一个服务商品”,而是:

一个带竞争控制、订单确认和付款落账语义的 Booth 资源。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。