POS 退款回链

Odoo POS 退款为什么不是“数量改成负数就完了”:原单回链、可退上限与批次序列号防串单讲透

很多人以为 POS 退款就是新建一张负数单,但 Odoo 真正在保护的是“这次到底在退哪一笔、还能退多少、批次/序列号有没有被重复退、退款单能不能随便换客户”。本文结合 point_of_sale 前后端源码,把 refund line、refunded_orderline_id 与 lot 选择边界讲透。

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

很多门店对 POS 退款的理解非常朴素:

  • 找到原单;
  • 把商品数量改成负数;
  • 收银员退钱;
  • 结束。

这套理解只覆盖了表面动作,却没有回答最关键的问题:你退的是哪一条原始销售线?还能退多少?如果商品带序列号,退回来的到底是哪一件?

Odoo 在 POS 退款上花了不少力气去把这些边界卡死。从 /home/ubuntu/odoo-temp/addons/point_of_sale/static/src/app/models/pos_order_line.jspos_store.jsmodels/pos_order.py 可以看出,退款单不是一张“负数复制品”,而是一张必须牢牢挂回原单证据链的反向订单。

结论先说

POS 退款想看懂,先记住四句话:

  1. 退款行必须知道自己在退谁,核心是 refunded_orderline_id
  2. 负数不是随便填的,数量只能在可退上限内;
  3. 带 lot / serial 的商品,退款不是只退数量,还要退到具体批次/序列号
  4. 一旦订单已经带退款线,客户就不能随便改,因为退款对象与客户责任被绑定了。

为什么退款不是“复制原单再乘以 -1”

前端模型里,退款线不是孤立存在的。

pos_order_line.jssetQuantity() 逻辑里,系统会先检查这条线的 refunded_orderline_id 是否落在当前退款上下文里。如果是,就按原单已退数量计算 maxQtyToRefund,并强制:

  • 正数不允许;
  • 超过可退上限不允许;
  • 只能填负数,且绝对值不能大于剩余可退数。

这说明 Odoo 在保护的不是“退货界面格式”,而是:

一条退款线必须能解释自己到底在冲销哪条历史销售线。

如果没有这条回链,系统就没法可靠判断:

  • 这次是不是重复退了;
  • 这笔退款是不是超出了原销售数量;
  • 后续统计里这是不是原单的真正反向业务。

为什么正数退款会被硬拦

源码中的提示写得很直白:

  • Positive quantity not allowed
  • Only a negative quantity is allowed for this refund line

很多人会把这看成前端体验限制,其实这是业务事实约束。

因为同一张单里如果退款线还能写成正数,就会把两种语义混在一起:

  • 正向新增销售;
  • 反向冲销既有销售。

一旦混起来,后面很多东西都会失真:

  • 原单剩余可退数量;
  • 退款统计;
  • 相关支付与找零逻辑;
  • 库存回流归因。

所以 Odoo 的做法不是“尽量宽容”,而是直接切断歧义。

为什么退款单不能随便换客户

pos_store.js 里还有一个很关键但很容易被忽略的限制:如果当前订单已经有 refund lines,再去切换 customer,系统会弹窗拒绝。

原因非常现实。

退款单一旦挂上原销售线,它就不再只是“一个购物车”,而是带有明确责任归属的反向凭证。此时如果还允许换客户,系统马上会进入一种含糊状态:

  • 退款来源是 A 客户的历史购买;
  • 退款单却挂在 B 客户名下;
  • 后续会员、余额、应收、发票归因都会混乱。

所以这里 Odoo 的原则其实很稳:

退款对象和客户归属一旦形成链路,就不能再随意拆开。

lot / serial 为什么让退款难度陡增

普通商品退款,难点主要在数量。

但带 lot / serial 的商品,难点会升级成:

  • 你退的是不是原来卖出的那批;
  • 某个序列号是否已经被退过;
  • 当前门店还能不能从现有 lot 池里乱选一个名字冒充退货。

editLotsRefund() 的做法非常典型:

  1. 先拿原销售线的 pack_lot_ids 作为候选;
  2. 再把已经在其他已确认退款线中退过的 lot / serial 名称剔除;
  3. 最后只允许从剩余候选中选,不走任意自定义输入。

这背后的意思非常明确:

退款 lot 不是重新编一个,而是要从原交易凭证里回捞。

尤其在 serial 跟踪场景里,这一点几乎是底线。因为序列号商品天然要求“一件一号一去向”,不能今天卖的是 A001,明天退款却随手录成 A009。

为什么有的退款 lot 可以自动带出,有的却要人工选

源码里对 lot 编辑分了正常销售与退款两条路:

  • 正常销售时,editLots() 会看 picking type 是否允许创建新 lot、是否必须用 existing lots,以及草稿单里已有 lot 是否已被占用;
  • 退款时,editLotsRefund() 更强调从原销售线可追溯地选回去。

所以你会看到两种看起来“很像 lot 选择”的界面,实际上目标完全不同:

  • 销售 lot 选择:是为了决定这次卖出哪批;
  • 退款 lot 选择:是为了证明这次退回的是之前卖出的哪批。

后端为什么还要再处理 returned cash

退款不只是订单线负数化,支付层也要闭环。

models/pos_order.py_process_payment_lines() 里,如果不是 draft 且存在 amount_return,后端会主动用现金支付方式补一条 is_change=True 的 return payment。并且如果当前 session 没有现金支付方法,还会直接报错。

这件事透露出一个很重要的边界:

POS 退款不是只在商品行上做负号,支付侧也必须有一条可解释的返现记录。

否则你前台看起来像“退成功了”,但现金箱与会话结账就会对不上。

最容易误解的四件事

误解一:退款线能手工随便改数量,系统自己会兜底

不对。

Odoo 是先按原线 qty - refundedQty 算剩余可退量,再决定你能不能继续退。

误解二:退款单换个客户问题不大

问题很大。

一旦退款线已挂到原销售链,改客户会破坏责任归属和后续核算语义。

误解三:带序列号商品退款时,只要数量对就行

不对。

serial 跟踪的关键不只是数量,而是“退回的是不是原先卖出的那一件”。

误解四:退款只是前端行为,后端不会再改

不对。

后端仍会处理 amount_return、现金返还记录以及订单正式状态,这些都不是前端自己说了算。

实战排错顺序

当你遇到“超退、退错客户、序列号退不回、退款金额对不上”时,建议按这个顺序查:

  1. 当前退款线是否真正带有 refunded_orderline_id
  2. 原始销售线的 qtyrefundedQty 分别是多少;
  3. 前端是否错误把退款线数量改成了正数或超过剩余上限;
  4. 当前订单是否已存在 refund lines,从而禁止切换客户;
  5. 若是 lot / serial 商品,当前可选候选是否来自原销售线;
  6. 某个 lot / serial 是否已在别的有效退款单里退过;
  7. 后端 _process_payment_lines() 是否成功生成 return payment;
  8. 当前 session 是否具备可用现金支付方式来承接返现。

最后的结论

Odoo POS 的退款机制,最重要的不是“负数怎么录”,而是:

  • 这次冲销到底对应哪条原始交易;
  • 还能退多少;
  • lot / serial 是否还能保持可追溯;
  • 支付与现金箱有没有形成闭环。

所以更准确的理解应该是:

POS 退款是一条带着原单回链、数量上限、批次序列号证据和返现记录的反向业务链,而不是一张随手录的负数订单。

DISCUSSION

评论区

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