先说结论
Odoo 的 Event Booth Sale 不是“销售一条 booth 商品线,然后订单确认就结束”。
从 /home/ubuntu/odoo-temp/addons/event_booth_sale 里的实现看,系统把展位销售拆成了四个层次:
- 产品层:某个商品是否被定义为
service_tracking = event_booth - 意向层:销售订单行上先挂
event_booth_pending_ids - 确认层:
event.booth.registration把订单行和展位绑起来,并在确认时处理抢占冲突 - 落定层:发票付款后,展位再被标记为
is_paid
所以它不是“卖一个 Booth 产品”,而是:
先登记想要哪个展位,再确认这个展位还能不能拿到,最后在付款后把占位关系彻底落定。
第一层:为什么展位必须是特殊 tracking 的服务产品
product.template 在 event_booth_sale 里扩展了 service_tracking:
- 新增
event_booth
而且一旦选择这个 tracking:
invoice_policy会被改成orderservice_tracking还会被加入 blacklist 控制- 如果产品或变体已经被某个
event.booth.category使用,就不允许你再把它改回别的 tracking
这背后的业务语义很清楚:
- 展位不是普通库存品
- 也不是标准项目服务
- 它是一种“会映射到具体 Booth 资源”的销售对象
因此 Odoo 先在产品层把它单独建模,避免你把同一个产品半路改成别的服务类型,把整个展位分类体系弄坏。
第二层:为什么销售订单行先挂的是 pending booth,而不是最终 booth
sale.order.line 里最关键的字段有三组:
event_booth_pending_idsevent_booth_registration_idsevent_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() 会做两件事:
- 调
registration.event_booth_id.action_confirm(values),把当前 registration 的关键信息写入 booth - 调
_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
评论区