很多人第一次看到 pos_event,都会下意识地把它理解成一句话:
在 POS 里卖活动门票,本质上就是卖一个 product。
这句话不算全错,但如果你只停在这里,后面几乎一定会看不懂这些现象:
- 为什么一张票卖出后,可售座位数会在其他 POS 会话里同步变化;
- 为什么退票不是简单冲一张负数单,而会取消报名记录;
- 为什么 paid 订单读回前端时,还会把
event.registration、event.slot、问卷答案一起带回来; - 为什么某些买票订单还会自动发 badge 邮件。
先说结论:在 Odoo 里,POS 活动票从来不是“普通商品换个名字”。它真正销售的是“门票商品 + 报名实体 + 座位占用 + 退款取消语义”的组合。
活动票为什么不是普通商品
在 pos_event 里,pos.order.line 被扩展了两个关键字段:
event_ticket_idevent_registration_ids
这说明一条 POS 订单行不只是“卖了某个产品”,它还能直接挂一组报名记录。
也就是说,前台卖出一张活动票后,后台真正沉淀下来的不仅是销售事实,还有:
- 这张票对应哪个 event;
- 用了哪种 ticket;
- 形成了哪些 attendee / registration;
- 是否有 slot、问卷答案等附加上下文。
所以活动票在 POS 里的真实对象,不只是 order line,而是 order line 背后的 registration 集合。
paid 订单为什么会带回一整套 event 数据
pos_event.models.pos_order.read_pos_data() 很值得注意。它在父类返回 POS 订单数据后,还会针对 paid / done / invoiced 的订单,额外把这些数据一起读回:
event.registrationevent.eventevent.event.ticketevent.slotevent.registration.answer
这很关键,因为它说明 Odoo 的目标不是“卖完票就跟 POS 无关了”,而是让 POS 前后端都能继续拿到完整票务上下文。
比如门店现场可能还需要:
- 重看 attendee 信息;
- 打印 full page ticket;
- 打印 badge;
- 核对 slot 或报名答案。
为什么卖出一张票后,其他会话座位数也会变化
event.registration.create() / write() 之后,会触发 _update_available_seat();而这个方法会找到所有未关闭的 pos.session,再调用 config._update_events_seats()。
_update_events_seats() 会通过 bus 推送 UPDATE_AVAILABLE_SEATS,里面带着:
- event 的剩余座位;
- 每个 ticket 的剩余座位;
- 每个 slot 的剩余座位。
这意味着:活动票的库存不是等门店手动刷新才变化,而是会主动广播给所有 open session。
这和普通商品库存认知很不一样。因为活动票卖掉后,系统更关心的是“剩余报名容量”,而不是仓库货架上的实物数量。
event.registration 在 POS 语义里扮演什么角色
event.registration 被扩展成 pos.load.mixin,并新增:
pos_order_idpos_order_line_id
同时 _compute_registration_status() 里会根据 POS 订单状态决定 registration 的状态:
- 订单取消 → registration 取消;
- 金额为 0 →
sale_status='free'且state='open'; - 正常售出 →
sale_status='sold'且state='open'。
这说明 registration 不是外围附属表,而是门票销售语义的核心承载体。
POS 卖票真正想维护的是:
是谁,以什么票种,在什么事件/时段下,获得了一个有效报名名额。
退款时系统到底取消了什么
pos_event.models.pos_order._process_order() 对退款有一段非常关键的处理。
它会:
- 找出退款行对应的原
pos.order.line; - 看原行上是否存在
event_registration_ids; - 计算本次退款数量与已取消数量;
- 只把尚未取消的那部分 registration 改成
cancel。
这意味着退票不是粗暴“原单整票作废”,而是尽量按退款数量精确取消报名名额。
所以活动票退款真正被撤回的,不只是销售金额,还包括 attendee 占掉的 event capacity。
为什么活动票退款比普通商品退款更需要原单回链
普通商品退款,很多时候只要金额和库存链闭环就行;但活动票还得处理:
- 取消哪几个报名名额;
- 剩余名额是否重新开放;
- 已取消和未取消 registration 如何避免重复取消;
- 后续 badge / attendee 名单如何同步变化。
因此活动票退款特别依赖原 order line 的回链,而不只是负数量本身。
自动发 badge 邮件说明了什么
read_pos_data() 里对 registration 还会做一件事:若 registration 有 email,则调用 action_send_badge_email()。
这件事很能说明 Odoo 的设计方向:POS 卖票不是“前台收完钱就收口”,而是把活动后续触达也串起来。
所以如果门店问“为什么在 POS 卖票也会触发 badge 邮件”,这不是旁支功能,而是票务销售链的一部分。
最容易误解的三件事
误区一:POS 活动票就是普通 product 销售
不对。它还会产生 registration、slot 和问卷答案等实体。
误区二:座位数变化只跟后台活动模块有关
不对。POS 卖票和退票也会主动驱动 seat availability 广播。
误区三:退票只是在财务上做负数冲回
也不对。它还会取消对应 attendee registration,占用名额也随之释放。
实战排错顺序
遇到“门票卖出后座位没更新 / 退票后 attendee 没取消 / POS 前端看不到票务上下文”时,建议按这个顺序查:
- 先看订单行上是否正确写入
event_ticket_id; - 再看
event_registration_ids是否真的生成; - 看相关
registration的state与sale_status是否符合订单状态; - 看
_update_available_seat()是否触发、open session 是否收到UPDATE_AVAILABLE_SEATS; - 若是退款问题,检查原单回链与已取消数量计算是否正确;
- 若前端信息不全,再查
read_pos_data()是否把 event 相关模型一起带回。
最后的结论
POS 卖活动票之所以复杂,不是因为 Odoo 过度设计,而是因为“卖票”本来就不是单纯卖一件商品。
它至少同时涉及四层语义:
- 销售订单;
- 报名实体;
- 座位容量;
- 退款取消与后续通知。
所以别再把 pos_event 理解成“POS 里卖个 event ticket product”。
它真正卖出去的,是一个可被追踪、可被取消、会占座位、还能继续触达参会人的报名资格。
DISCUSSION
评论区