Odoo 14 → 18 升级踩坑实录:委外收货「退换货」报 location_dest_id 未设置的完整排查


  • 现象:委外采购收货单点「退货 / 退换货」,报 stock.picking.location_dest_id 必填字段未设置。
  • 真凶:Odoo 官方委外模块 mrp_subcontracting 在退货时把目标位置强制改成 partner.property_stock_subcontractor;而升级到 Odoo 18 后,这个「公司级默认值」没有被迁移到新机制 ir.default 里,读出来是空 → .id 变成 False → 写入必填字段 → 数据库 NOT NULL 拦截。
  • 和 ITG18 自定义模块无关(全库 0 行代码触及退货/location)。
  • 和单据数据无关(源单 location_id=8、location_dest_id=15,全库 0 条 NULL)。
  • 修复:一条 ir.default 补齐即可,全量生效。

一、现象

界面操作:委外采购收货单 WH/IN/06572(来源库位 Physical Locations/Subcontracting Location)→ 点击「退货 / 退换货」。

报错:

操作无法完成:
- 创建/更新:未设置必填字段。
- 删除:另一个模型要求记录被删除。如果可能,请将其存档。

模式:Transfer (stock.picking)
字段:Destination Location (location_dest_id)

数据库层真正的错误:

INSERT INTO "stock_picking" (..., "location_dest_id", "location_id", ..., "return_id", ...)
VALUES (..., NULL, 15, ..., 34484, ...)
ERROR: null value in column "location_dest_id" of relation "stock_picking"
       violates not-null constraint

关键信息全在这一行里:

列值含义
nameWH/OUT/10911新建的是一张出库单(退货单)
origin退回WH/IN/06572Return of WH/IN/06572
return_id34484源单 = 那张收货单
picking_type_id5交货单(outgoing)
location_id15源单的目标位置(WH/暂收区)✓
location_dest_idNULL源单的源位置 ✗

​而那条 SQL 上的 WARNING 只是 odoo/service/model.py:100-112 把 PostgreSQL 的 NotNullViolation 翻译成人类语言的包装层,它本身不是原因。

二、影响面

  • 只影响委外(subcontracting)收货单的退货/退换货。
  • 普通采购收货退货、普通销售发货退货、内部调拨退货全部正常。
  • 这也是为什么问题藏了很久才被发现——业务上先跑了很久普通单据。

三、排查路径(四层排除法)

第 1 层:怀疑 ITG18 自定义模块 → 排除

对 ITG18/ 做了全量审计:

检查项结果
继承 stock.picking 的模型仅 2 个:zd_version_number_picking_number(加 related 字段)、quality_control_stock_oca(加质检按钮)
改写 location_dest_id / location_id0 处(唯一一处是 mrp_workorder 对 stock.move.line 写入,与 picking 无关)
覆写 _compute_location_id / _onchange_picking_type0 处
视图 xpath 隐藏/改属性 location_dest_id0 处(全 ITG18 XML 根本没出现过这个字符串)
ir.model.fields 覆写 required/readonly0 处
退换货 / 退货 / RMA 代码0 处(中英文全搜无结果)
stock.return.picking / _create_return 扩展0 处

结论:ITG18 不背锅。

第 2 层:怀疑数据 → 排除

线上库实测:

-- 源单
SELECT id, name, state, company_id, picking_type_id, location_id, location_dest_id
FROM stock_picking WHERE id = 34484;
-- 34484 | WH/IN/06572 | done | 1 | 2 | 8 | 15   ← 完全正常

-- 全库脏数据
SELECT count(*) FILTER (WHERE location_id IS NULL), count(*) FILTER (WHERE location_dest_id IS NULL)
FROM stock_picking;
-- 0 | 0

-- 作业类型
SELECT id, name, code, default_location_src_id, default_location_dest_id, return_picking_type_id
FROM stock_picking_type WHERE id IN (2,5);
-- 2 | 收货   | incoming | 8  | 15 | 5
-- 5 | 交货单 | outgoing | 16 | 9  | 28   ← 全部正常

结论:数据没问题。

第 3 层:本地复现 → 抓到 mrp_subcontracting

在本地 Odoo 18 用同样的数据调 _create_return(),一模一样的报错,同一个产品 Paint-00006:

ERROR: null value in column "location_dest_id" ...
Failing row contains (31345, null, WH/OUT/10111, 退回WH/IN/05334, ..., 13, null, 5, 676, ..., 28858, ...)

继续拆解,发现:

w._prepare_picking_default_values_based_on(p)   # → loc=13, dest=8   ✅ 正确
w._prepare_picking_default_values()             # → loc=13, dest=False ❌ 被覆盖

两者差在哪?mrp_subcontracting 覆写了不带参数的那个方法:

addons/mrp_subcontracting/wizard/stock_picking_return.py:10-14

def _prepare_picking_default_values(self):
    vals = super()._prepare_picking_default_values()
    if all(return_line.quantity > 0 and return_line.move_id.is_subcontract
           for return_line in self.product_return_moves):     # 全是委外移动
        vals['location_dest_id'] = self.picking_id.partner_id \
            .with_company(self.picking_id.company_id) \
            .property_stock_subcontractor.id               # ← 空就是 False
    return vals

第 4 层:为什么这个属性是空的 → 升级机制差异

打印出来:

ir_property 里:property_stock_subcontractor | res_id=NULL | company=1 | stock.location,269
ORM 读出来:    partner.property_stock_subcontractor = EMPTY

原因在 Odoo 18 的字段存储变更:

# odoo/fields.py:774
return ('jsonb', 'jsonb') if self.company_dependent or self.translate else self._column_type

# odoo/fields.py:785-790
def get_company_dependent_fallback(self, records):
    fallback = records.env['ir.default']._get_model_defaults(records._name).get(self.name)

Odoo 18 把 company_dependent 字段从 ir_property 表改成了「模型表上的 JSONB 列 + ir.default 兜底」。

而 mrp_subcontracting 本来会在建公司时写这条默认值:

addons/mrp_subcontracting/models/res_company.py

def _create_subcontracting_location(self):
    ...
    self.env['ir.default'].set(
        "res.partner", "property_stock_subcontractor",
        subcontracting_location.id, company_id=company.id,
    )

这个方法只在新公司创建时执行。升级已有公司时不会重跑,而 v14 时代的信息还躺在 ir_property 里——Odoo 18 已经不读它了。

线上 ir.default 实测:

property_stock_supplier     | (company NULL) | 8     ✅ 有
property_stock_customer     | (company NULL) | 9     ✅ 有
property_stock_subcontractor| —— 没有 ——            ❌ 缺

四、根因链(三层叠加)

① 升级迁移缺口
   Odoo 18 把 company_dependent 从 ir_property 改为 JSONB + ir.default
   ↓
   property_stock_subcontractor 的「公司级默认值」没迁到 ir.default
   ↓
② 上游代码无空值兜底
   mrp_subcontracting 退货时无条件 .id,空记录集 → False
   ↓
③ 必填字段 + 数据库约束
   location_dest_id required=True(NOT NULL)
   ↓
   退货单 INSERT 写 NULL → NotNullViolation → 界面报「必填字段未设置」

所以「是升级脚本的问题吗?」——可以这么说,但要精确:

不是升级脚本「写错了代码」,而是迁移覆盖不全:Odoo 18 的存储机制变了,ir_property 中 res_id IS NULL 的公司级默认行没有转换成 ir.default 记录(至少在 property_stock_subcontractor 上漏了)。再叠加 mrp_subcontracting 缺少空值兜底,两者同时成立才会爆炸。

顺带一提,升级还漏了 stock_location.is_subcontracting_location 这个标记(两个委外库位都是 NULL),说明这次升级对 mrp_subcontracting 相关数据确实存在系统性遗漏。

五、修复(已执行)

按 Odoo 官方 _create_subcontracting_location() 的写法补 ir.default:

# docker compose exec web odoo shell -d ITG --no-http
for company in env['res.company'].search([]):
    loc = company.subcontracting_location_id
    if loc:
        env['ir.default'].set(
            'res.partner', 'property_stock_subcontractor',
            loc.id, company_id=company.id,
        )
env.cr.commit()

同时补上遗漏的标记:

UPDATE stock_location SET is_subcontracting_location = true WHERE id IN (269, 1013);

用纯 SQL 插 ir_default 也可以,但必须重启 Odoo 容器清 ORM 缓存;用 ir.default.set() 更保险。

六、验证

委外位置 = Physical Locations/Subcontracting Location
退货单: WH/OUT/10912 | WH/暂收区 -> Physical Locations/Subcontracting Location

修复前 NotNullViolation,修复后正确生成退货单,目标位置落在委外库位。

失败的那次 INSERT 整个事务回滚,没有任何脏数据需要清理。

七、经验与预防

1. 升级后必查:company_dependent 字段的默认值

-- v14 有、18 没有的公司级默认值("失联"清单)
SELECT f.model, f.name, p.company_id, p.value_reference AS legacy_value
FROM ir_property p
JOIN ir_model_fields f ON f.id = p.fields_id
WHERE p.res_id IS NULL
  AND NOT EXISTS (
      SELECT 1 FROM ir_default d
      WHERE d.field_id = f.id
        AND d.company_id IS NOT DISTINCT FROM p.company_id
  )
ORDER BY f.model, f.name, p.company_id;

本次还发现 res.partner.property_account_payable_id / property_account_receivable_id 只有公司 1 有默认值,公司 2(ITG-HK)缺失——如果那边要开票做账,早晚会炸,建议一并补。

2. 多公司环境要按公司逐一验证

property_* 这类字段是 company_dependent,默认值也是按公司存的。只测了公司 1 不代表公司 2 没问题。

3. 升级验收清单里加一条

「委外采购收货 → 退货 / 退换货」「采购收货 → 退货」「销售发货 → 退货」三种退货路径全部走一遍。

这类问题只在特定业务路径上暴露,跑一遍主流程完全看不出来。

4. 代码层面加兜底(可选)

在 ITG18 加一个小模块,属性为空时回退,绝不把 False 写进 required 字段:

# ITG18/zd_stock_return_fix/models/stock_picking_return.py
from odoo import models

class ReturnPicking(models.TransientModel):
    _inherit = 'stock.return.picking'

    def _prepare_picking_default_values(self):
        vals = super()._prepare_picking_default_values()
        if not vals.get('location_dest_id'):
            company = self.picking_id.company_id
            fallback = (self.picking_id.partner_id.with_company(company).property_stock_subcontractor
                        or company.subcontracting_location_id
                        or self.picking_id.location_id)
            vals['location_dest_id'] = fallback.id
        return vals

manifest 依赖:['mrp_subcontracting', 'stock']。

5. 给 Odoo 提 upstream issue

property_stock_subcontractor.id 为空时应当回退到 company.subcontracting_location_id,而不是直接把 False 写进 required=True 的字段。这属于上游健壮性缺陷。

八、关键文件索引

文件作用
addons/mrp_subcontracting/wizard/stock_picking_return.py:10-14直接触发点:退货时强制改目标位置
addons/mrp_subcontracting/models/res_company.py官方补默认值的正确写法(ir.default.set)
addons/stock/wizard/stock_picking_return.py:137-153核心退货 vals 构造(dest = picking.location_id)
addons/stock/wizard/stock_picking_return.py:156-177_create_return() → picking.copy(vals)
addons/stock/models/stock_picking.py:606-613location_dest_id 定义(computed / store / required)
addons/stock/models/stock_picking.py:950-964_compute_location_id(return_id 存在时直接跳过)
odoo/fields.py:774 / 785-790Odoo 18 company_dependent → JSONB + ir.default 兜底
odoo/service/model.py:100-112NotNullViolation → 人类可读报错

一句话归档:这不是自定义模块的问题,也不是数据的问题,而是 Odoo 14→18 升级时 mrp_subcontracting 的 company_dependent 默认值(property_stock_subcontractor)未迁移到 ir.default,叠加上游退货向导缺少空值兜底,最终把 NULL 写进了必填字段。

关于我们

​我们致力于帮助中小企业实现数字化转型,我们的团队由一群充满激情和创新思维的专业人士组成,他们具备丰富的行业经验和技术专长。

扫一扫获取顾问以及手册

归档
登录 留下评论
Odoo 招聘流程指南:从寻觅人才到员工入职的全流程闭环
现代企业在招聘过程中普遍面临流程繁琐、沟通效率低下以及候选人数据分散等挑战。