很多门店对 POS 退款的理解非常朴素:
- 找到原单;
- 把商品数量改成负数;
- 收银员退钱;
- 结束。
这套理解只覆盖了表面动作,却没有回答最关键的问题:你退的是哪一条原始销售线?还能退多少?如果商品带序列号,退回来的到底是哪一件?
Odoo 在 POS 退款上花了不少力气去把这些边界卡死。从 /home/ubuntu/odoo-temp/addons/point_of_sale/static/src/app/models/pos_order_line.js、pos_store.js 和 models/pos_order.py 可以看出,退款单不是一张“负数复制品”,而是一张必须牢牢挂回原单证据链的反向订单。
结论先说
POS 退款想看懂,先记住四句话:
- 退款行必须知道自己在退谁,核心是
refunded_orderline_id; - 负数不是随便填的,数量只能在可退上限内;
- 带 lot / serial 的商品,退款不是只退数量,还要退到具体批次/序列号;
- 一旦订单已经带退款线,客户就不能随便改,因为退款对象与客户责任被绑定了。
为什么退款不是“复制原单再乘以 -1”
前端模型里,退款线不是孤立存在的。
在 pos_order_line.js 的 setQuantity() 逻辑里,系统会先检查这条线的 refunded_orderline_id 是否落在当前退款上下文里。如果是,就按原单已退数量计算 maxQtyToRefund,并强制:
- 正数不允许;
- 超过可退上限不允许;
- 只能填负数,且绝对值不能大于剩余可退数。
这说明 Odoo 在保护的不是“退货界面格式”,而是:
一条退款线必须能解释自己到底在冲销哪条历史销售线。
如果没有这条回链,系统就没法可靠判断:
- 这次是不是重复退了;
- 这笔退款是不是超出了原销售数量;
- 后续统计里这是不是原单的真正反向业务。
为什么正数退款会被硬拦
源码中的提示写得很直白:
Positive quantity not allowedOnly 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() 的做法非常典型:
- 先拿原销售线的
pack_lot_ids作为候选; - 再把已经在其他已确认退款线中退过的 lot / serial 名称剔除;
- 最后只允许从剩余候选中选,不走任意自定义输入。
这背后的意思非常明确:
退款 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、现金返还记录以及订单正式状态,这些都不是前端自己说了算。
实战排错顺序
当你遇到“超退、退错客户、序列号退不回、退款金额对不上”时,建议按这个顺序查:
- 当前退款线是否真正带有
refunded_orderline_id; - 原始销售线的
qty与refundedQty分别是多少; - 前端是否错误把退款线数量改成了正数或超过剩余上限;
- 当前订单是否已存在 refund lines,从而禁止切换客户;
- 若是 lot / serial 商品,当前可选候选是否来自原销售线;
- 某个 lot / serial 是否已在别的有效退款单里退过;
- 后端
_process_payment_lines()是否成功生成 return payment; - 当前 session 是否具备可用现金支付方式来承接返现。
最后的结论
Odoo POS 的退款机制,最重要的不是“负数怎么录”,而是:
- 这次冲销到底对应哪条原始交易;
- 还能退多少;
- lot / serial 是否还能保持可追溯;
- 支付与现金箱有没有形成闭环。
所以更准确的理解应该是:
POS 退款是一条带着原单回链、数量上限、批次序列号证据和返现记录的反向业务链,而不是一张随手录的负数订单。
DISCUSSION
评论区