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)

資料庫層真正的錯誤:

The provided segment contains no Simplified Chinese text — it is SQL code plus an English error message. Translating identifiers or SQL keywords would corrupt the statement, so the segment is returned unchanged:

```
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新建的是一張出庫單(退貨單)
起源退回WH/IN/06572WH/IN/06572 之退回
return_id34484源單 = 那張收貨單
picking_type_id5交貨單(outgoing)
location_id15源單的目標位置(WH/暫收區)✓
location_dest_idThe provided text segment is `NULL` (empty), so there is no content to translate. Please provide the actual Simplified Chinese text segment you'd like translated.源單的源位置 ✗

​而那条 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:

錯誤:欄位「location_dest_id」的值為 null ...
失敗的資料列包含 (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` (No translatable Chinese text is present in this segment; it is a file path with line numbers, so it is returned unchanged.)_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 招聘流程指南:從尋覓人才到員工入職的全流程閉環
現代企業在招聘過程中普遍面臨流程繁瑣、溝通效率低下以及候選人數據分散等挑戰。