POS 小费机制

Odoo POS 小费为什么不是“加一行服务费”:tip 产品、找零行与已打赏状态边界讲透

很多人把 POS 小费理解成订单尾部加一行金额,但 Odoo 真正在处理的是“这笔金额挂到哪个产品、订单是否已进入 tipped 状态、找零行和真实支付行怎么拆、会计与收银统计要不要把它当独立语义”。本文结合 point_of_sale 源码,把 tip_product、is_tipped 与 payment move 边界讲透。

POS
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多团队做餐饮或服务型 POS 时,会自然把“小费”理解成:

  • 顾客多付一点钱;
  • 订单最后加一行金额;
  • 反正总额变大了,系统自己会处理。

这理解只能算一半对。

/home/ubuntu/odoo-temp/addons/point_of_sale/models/pos_config.pystatic/src/app/services/pos_store.jsmodels/pos_payment.py 看,Odoo 并没有把小费当成“随手改总额”,而是明确把它建模成:

  • 一个专门的 tip product
  • 一个已经打赏过的 订单状态语义
  • 一套与正常支付、找零、拆账仍需分开的 支付记录边界

所以更准确的说法是:Odoo POS 小费不是尾部加价,而是一笔通过产品和支付链路显式表达出来的附加消费。

结论先说

POS 小费机制最值得先记住的,是这四点:

  1. 小费不是裸金额字段直冲总额,而是挂到 tip_product_id 对应的订单行;
  2. 订单是否已打赏 有专门状态,核心字段是 is_tippedtip_amount
  3. 小费不等于找零amount_returnis_change 是另一层支付语义;
  4. 会计上真正落账的不是“顾客多给了一点”这句话,而是订单线与 payment move 的结构。

为什么 Odoo 要求先有一个 tip product

pos_config.py 里,POS 配置不是简单打个“启用小费”勾,而是明确有:

  • iface_tipproduct
  • tip_product_id

并且当你开启小费功能却没给产品时,源码会尝试找默认 product_product_tip,找不到还会抛错。

这说明 Odoo 的设计态度非常明确:

小费必须作为一个可被订单、报表、打印、统计理解的业务行存在,而不是只作为总额差值存在。

为什么这很重要?因为如果小费只是一个总额尾差:

  • 小票上很难稳定展示;
  • 营业分析不知道哪些收入来自小费;
  • 某些税务或本地化场景难以决定它的归类;
  • 会计落账时也更容易变成一团解释不清的汇总数。

前端设置小费时,实际发生了什么

pos_store.jssetTip(tip) 逻辑很直白:

  • 先找当前订单里是否已经有 tip product 对应行;
  • 有就更新这条行的单价;
  • 没有就新增一条以 tip product 为商品的订单行;
  • 然后把 currentOrder.is_tipped = true,并写入 tip_amount

这意味着小费在 POS 里其实同时落在两层:

  1. 订单行层:这笔钱真实地作为商品行存在;
  2. 订单状态层:系统知道这张单已经打赏过,金额是多少。

这两层并存很有价值。

订单行层解决的是:

  • 展示;
  • 总额计算;
  • 打印;
  • 下游会计/分析。

状态层解决的是:

  • 快速判断订单是否已打赏;
  • 避免 UI 和业务逻辑把小费当普通商品线误读;
  • 让某些统计或前端流程可以直接识别“tip 语义”。

为什么小费不等于找零

很多现场问题就出在这里:顾客付了 110,订单 100,收银员以为多出来的 10 全是小费。

但 Odoo 在支付层把这两件事分得很清楚。

  • 小费:是订单愿意确认的一部分消费金额,进入订单总额;
  • 找零 / 返还:是支付时多收后需要退回的金额,对应 amount_return 和 payment 上的 is_change=True

models/pos_order.py_process_payment_lines() 里,如果存在 amount_return,后端会专门生成一条 return payment。它不是销售收入,也不是小费收入。

而在 pos_payment.py_create_payment_moves() 里,系统还会把现金主支付和 change line 组合处理,确保真正记入 payment move 的是净额语义,而不是把“顾客多给了、后来又找回”的动作当成营业收入。

所以一定要记住:

小费是顾客确认留给门店/服务员的金额;找零是本来就不属于订单收入的返还。

为什么 is_tipped 很重要

很多开发者看到 tip_amount 和 tip product line 都有,就会觉得 is_tipped 有点多余。

其实不多余。

因为真实业务里,“订单上有一条 tip 商品行”和“这张单已走过 tip 流程”并不完全是同一句话。显式的 is_tipped 会让系统在这些场景更稳定:

  • 前端重复点小费按钮时判断当前状态;
  • 报表或界面快速区分普通订单与打赏订单;
  • 某些定制逻辑不必靠猜测商品 ID 来反推订单语义。

这也是 Odoo 一贯的风格:当某个业务概念既会体现在行项目里,又会影响流程判断时,往往会同时保留结构表达语义标记

payment move 为什么还要在支付层再拆一次

到了 pos_payment.py,你会发现支付并不是“订单金额记一次就行”。

_create_payment_moves() 会根据支付方式、是否 split transaction、是否找零等条件,生成对应的会计 move 和 receivable line。对于现金多收再找零的情况,还会把主支付和 change payment 结合起来处理。

这套设计的意义在于:

  • 订单线负责表达卖了什么,其中包括 tip 产品;
  • payment line 负责表达钱怎么进、怎么退;
  • payment move 负责把这些动作翻译成可核对的会计结构。

换句话说,小费是商品/订单语义,找零是支付净额语义,它们在总额上会相遇,但在账务意义上不能混写。

最容易误解的四件事

误解一:小费就是订单总额比商品金额多出来的部分

不对。

在 Odoo 里,小费优先被建模为 tip product line,而不是事后由总额差推断。

误解二:多收少找也可以当小费

不对。

若顾客未明确确认,这更像找零处理,不应自然并入 tip 收入。

误解三:有 tip 商品行,就不需要 is_tipped

不对。

商品行表达结构,is_tipped 表达订单语义,两者解决的问题不同。

误解四:小费落账只看订单行,不用看支付层

也不对。

支付层仍要决定哪些是实际收款、哪些是找零返还、哪些 receivable 需要被怎样冲掉。

实战排错顺序

如果你遇到“小费显示不对、重复打赏、找零被算成小费、会计金额对不上”,建议按这个顺序查:

  1. POS 配置是否启用了 iface_tipproduct
  2. tip_product_id 是否存在且属于正确公司/配置;
  3. setTip() 是更新已有 tip 行,还是重复新建了多条;
  4. 订单上的 is_tippedtip_amount 是否和订单行一致;
  5. 顾客多付的金额里,哪些应进 tip,哪些其实是 amount_return
  6. 后端 _process_payment_lines() 是否生成了 change line;
  7. pos.payment._create_payment_moves() 是否按净额处理了支付与找零;
  8. 最终营业分析与会计报表是否把 tip 产品单独区分出来。

最后的结论

Odoo POS 小费机制最值得理解的,不是“怎么让总额加 10 块”,而是:

  • 这 10 块在订单里属于什么;
  • 它是不是被明确认可为小费;
  • 它和找零这种支付返还如何分家;
  • 最终如何落到可解释的会计与收银统计中。

所以这件事最准确的总结是:

POS 小费在 Odoo 里是一笔通过 tip 产品、订单语义标记和支付闭环共同表达的附加消费,而不是一个随手补上的金额差。

DISCUSSION

评论区

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