- 现象:委外采购收货单点「退货 / 退换货」,报 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
关键信息全在这一行里:
| 列 | 值 | 含义 |
|---|---|---|
| name | WH/OUT/10911 | 新建的是一张出库单(退货单) |
| origin | 退回WH/IN/06572 | Return of WH/IN/06572 |
| return_id | 34484 | 源单 = 那张收货单 |
| picking_type_id | 5 | 交货单(outgoing) |
| location_id | 15 | 源单的目标位置(WH/暂收区)✓ |
| location_dest_id | NULL | 源单的源位置 ✗ |
而那条 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_id | 0 处(唯一一处是 mrp_workorder 对 stock.move.line 写入,与 picking 无关) |
| 覆写 _compute_location_id / _onchange_picking_type | 0 处 |
| 视图 xpath 隐藏/改属性 location_dest_id | 0 处(全 ITG18 XML 根本没出现过这个字符串) |
| ir.model.fields 覆写 required/readonly | 0 处 |
| 退换货 / 退货 / 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-613 | location_dest_id 定义(computed / store / required) |
| addons/stock/models/stock_picking.py:950-964 | _compute_location_id(return_id 存在时直接跳过) |
| odoo/fields.py:774 / 785-790 | Odoo 18 company_dependent → JSONB + ir.default 兜底 |
| odoo/service/model.py:100-112 | NotNullViolation → 人类可读报错 |
一句话归档:这不是自定义模块的问题,也不是数据的问题,而是 Odoo 14→18 升级时 mrp_subcontracting 的 company_dependent 默认值(property_stock_subcontractor)未迁移到 ir.default,叠加上游退货向导缺少空值兜底,最终把 NULL 写进了必填字段。
