矩阵录单

Odoo 矩阵录单为什么不是前端多一个表格:variant matrix 的服务端落单、冲突保护与报表回填讲透

很多人把 Sales Product Matrix 理解成“销售单上多一个二维表”;但标准 Odoo 明确把矩阵读取、变体生成、数量差异计算和销售行更新都放在服务端完成,原因是前端并不会加载全部订单行。它还会处理 no-variant 属性、重复销售行冲突、草稿/已确认单的删改边界,并支持把已录矩阵反填回报价 PDF。本文把这条链讲透。

前端 销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

先抓主线

Sales Product Matrix 最容易被低估成一个 UI 小功能:

  • 选一个产品模板
  • 弹一个颜色 × 尺码矩阵
  • 填数量
  • 自动生成销售行

但标准 Odoo 的源码一开头就已经把重点说出来了:

矩阵功能放在 python / server side 完成,而不是只在 JS 里做。

原因也写得很直白:

  • Web 客户端不会把订单里的所有行都加载到前端
  • 大矩阵和长订单下,前端只能看到前面一部分
  • 如果只靠 JS 直接改行,很容易漏掉第 41 行之后的数据

所以这个功能真正解决的不是“显示一个表格”,而是:

如何在大订单场景下,仍然可靠地把矩阵输入变成正确的 sale.order.line 变化。


这篇文章主要参考哪些源码

核心参考文件是:

  • /home/ubuntu/odoo-temp/addons/sale_product_matrix/models/sale_order.py

最关键的方法包括:

  • _set_grid_up()
  • _apply_grid()
  • _get_matrix()
  • get_report_matrixes()

这个文件很适合讲“前端交互外观之下,其实是服务端数据编排”。


第一层:为什么矩阵不是纯前端逻辑

源码注释已经给出答案:

  • JS framework 只加载当前显示的前几行
  • 大矩阵、大量销售行时,前端并不拥有完整订单状态
  • 如果强行只在浏览器里改,后面的销售行会失真或被忽略

因此标准设计选择:

  • 前端只负责把矩阵选择结果存进字段
  • 服务端负责真正解释这些变化,并更新 order_line

这类设计很典型:

  • UI 看起来像前端功能
  • 但可靠性要求决定了核心逻辑必须在后端收口

第二层:打开矩阵时,系统先把“当前真实状态”序列化出去

_set_grid_up()grid_product_tmpl_id onchange 时触发。

它做的事情是:

  • grid_update = False
  • 再把 _get_matrix(product_template) 的结果 JSON 序列化到 grid

也就是说,打开矩阵不是只画一个模板空表,而是先把:

  • 这个产品模板有哪些组合
  • 当前订单里这些组合已录了多少数量

一起打包给前端。

所以矩阵界面显示的,不是“产品模板定义”,而是“产品模板定义 + 当前订单真实落单状态”。


第三层:真正落单时,标准只处理 dirty cells,而不是整张表重算

_apply_grid() 会在:

  • self.grid 有值
  • self.grid_update == True

时工作。

它读取 JSON 后,重点只取:

  • grid['changes']

也就是本次用户修改过的 cells。

这一步很重要,因为它说明矩阵更新不是暴力重建全部订单行,而是:

基于脏单元格增量更新订单。

这样做的好处是:

  • 降低改动面
  • 少碰没变化的行
  • 避免已有业务字段、备注、组合项被无谓重写

第四层:变体不是预先假定存在,而是按组合即时创建/获取

对每个 dirty cell,标准会:

  1. 读取 ptav_ids
  2. 通过 product.template.attribute.value 还原组合
  3. 分离出 no_variant_attribute_values
  4. 调用 product_template._create_product_variant(combination)

这里非常关键。

矩阵录单并不是只在现成 SKU 里选,而是基于属性组合去定位/创建对应变体,然后再找订单里有没有同款行。

这解释了两个常见现象:

  • 为什么有的矩阵组合第一次录入时系统会“长出”变体
  • 为什么 no-variant 属性也会影响找行逻辑

因为标准并不只按 product_id 粗暴匹配,还会同时比较:

  • product_id
  • product_no_variant_attribute_value_ids
  • 并排除 combo_item_id

第五层:矩阵修改的是“目标总数量”,不是“加减一笔动作”

源码会先算:

  • 订单里该组合已有的 old_qty
  • 本次 cell 目标数量 qty
  • 然后取差值 diff = qty - old_qty

如果 diff == 0,就直接跳过。

这说明矩阵不是在记录“本次新增了几件”,而是在表达:

这个组合在订单里的期望总数量现在应该是多少。

因此:

  • 若已有行且目标数量非零,系统会尝试更新该行数量
  • 若已有行且目标数量变成 0,在 draft/sent 才允许直接删行
  • 若订单已确认,就不删,只把 product_uom_qty 设成 0
  • 若本来没有对应行,则新建 sale order line

这正是矩阵功能在业务边界上很成熟的地方:

  • 草稿阶段可以更激进地重排
  • 已确认阶段则尽量保留交易痕迹

第六层:为什么“同一变体出现多条销售行”会直接报错

这是标准最值得点赞的保护之一。

如果矩阵发现:

  • 同一个 product variant
  • 同一组 no-variant 属性
  • 在订单中匹配出 多条 普通销售行

它不会尝试“智能合并”,而是直接抛:

  • You cannot change the quantity of a product present in multiple sale lines.

源码注释解释得非常诚实:

  • 理论上可以把第一条改数量、删除其余几条
  • 但这样会丢掉其他行背后绑定的业务逻辑

所以标准宁可报错,也不装作自己能安全合并。

这其实是一种很成熟的保守策略:

  • 发现语义不唯一,就停止自动化
  • 让用户先把多条业务行梳理干净

第七层:新建行时为什么还要拿默认值和 sequence

如果矩阵里某个组合之前没有销售行,_apply_grid() 不会裸造一条最小数据行。

它会先:

  • sale.order.linedefault_get()
  • 继承必要默认值
  • 并读取最后一条 order line 的 sequence

然后再构造新行。

这表示矩阵新建行不是绕过标准销售行生命周期,而是尽量在标准默认值体系里生成。

好处很明显:

  • 税、单位、默认描述、默认行为更容易保持一致
  • 自定义 default 逻辑也更容易一起生效

第八层:为什么报表里还能把矩阵状态反填回来

_get_matrix() 除了给前端开矩阵,还负责从当前订单里把数量反填回矩阵结构。

get_report_matrixes() 则进一步:

  • 只在 report_grids 开启时输出
  • 只处理矩阵模式产品模板
  • 只有某模板在订单里出现多行时才有展示价值
  • 并过滤掉数量全为 0 的行

这意味着矩阵不是录单时的一次性交互,而是还能变成报价/报表的展示语义。

换句话说:

  • 前端录的不是临时 UI 数据
  • 而是一种可被再次渲染的订单结构

新手最容易误解的 5 件事

1. 以为矩阵录单只是前端功能

标准明确把关键更新逻辑放在服务端。

2. 以为矩阵提交会重建整张订单

不是。它只处理 dirty cells。

3. 以为匹配订单行时只看 product_id

不是,还要看 no-variant 属性,并排除 combo item。

4. 以为重复销售行可以自动安全合并

标准宁可报错,也不冒险吞掉业务语义。

5. 以为矩阵只是录单时临时界面

它还能被反填到报表矩阵里继续展示。


实战调试顺序

如果你排查“矩阵更新后数量不对”,建议看:

  1. 前端传回的 grid['changes'] 是什么
  2. 组合对应的 product_id 是否被正确创建/定位
  3. no_variant_attribute_values 是否与已有销售行一致
  4. 订单里是否已经存在同组合的多条销售行
  5. 当前订单状态是 draft/sent 还是已确认,决定了删行还是归零

如果你排查“报表矩阵为什么没显示”,重点看:

  1. report_grids 是否开启
  2. 产品模板是否真的是 matrix add mode
  3. 该模板在订单里是否有多条销售行
  4. 矩阵各行是否全为 0,导致被过滤

一句话记忆法

Odoo 矩阵录单不是前端多一个表格,而是把用户改动的脏单元格交给服务端去解释:创建/定位变体、比较目标总量、保护重复销售行,并把最终结构再反填到报表矩阵。

DISCUSSION

评论区

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