- 現象:委外採購收貨單點「退貨 / 退換貨」,報 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
```
關鍵訊息全在這一行裡:
| 列 | 值 | 含義 |
|---|---|---|
| name | WH/OUT/10911 | 新建的是一張出庫單(退貨單) |
| 起源 | 退回WH/IN/06572 | WH/IN/06572 之退回 |
| return_id | 34484 | 源單 = 那張收貨單 |
| picking_type_id | 5 | 交貨單(outgoing) |
| location_id | 15 | 源單的目標位置(WH/暫收區)✓ |
| location_dest_id | The 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_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:
錯誤:欄位「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-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 寫進了必填欄位。
