- Symptom: On the subcontracting purchase receipt, clicking "Return / Return or Exchange" reports that the required field stock.picking.location_dest_id is not set.
- Real culprit: The official Odoo subcontracting module mrp_subcontracting forcibly changes the destination location to partner.property_stock_subcontractor during returns; and after upgrading to Odoo 18, this "company-level default value" was not migrated to the new mechanism ir.default, so reading it returns empty → .id becomes False → writes to a required field → database NOT NULL blocks it.
- Unrelated to the ITG18 custom module (0 lines of code in the entire repository touch returns/location).
- Unrelated to document data (source document location_id=8, location_dest_id=15; 0 NULL records in the entire database).
- Fix: Just one ir.default entry is enough to fill the gap, and it takes effect globally.
1. Phenomenon
UI operation: Subcontracting purchase receipt WH/IN/06572 (source location Physical Locations/Subcontracting Location) → click “Return / Return or Exchange”.
Error:
Operation cannot be completed: - Create/Update: Required fields are not set. - Delete: Another model requires the record to be deleted. If possible, please archive it. Mode: Transfer (stock.picking) Field: Destination Location (location_dest_id)
The real error at the database layer:
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
All the key information is in this line:
| Column | value | Meaning |
|---|---|---|
| name | WH/OUT/10911 | The newly created one is an outbound order (return order). |
| origin | Return WH/IN/06572 | Return of WH/IN/06572 |
| return_id | 34484 | Source document = that receiving note |
| picking_type_id | 5 | Delivery Note (outgoing) |
| location_id | 15 | Source document's target location (WH/Temporary Receiving Area) ✓ |
| location_dest_id | No text to translate — the provided segment is NULL (empty). | Source location of the source document ✗ |
而那条 SQL 上的 WARNING 只是 odoo/service/model.py:100-112 把 PostgreSQL 的 NotNullViolation 翻译成人类语言的包装层,它本身不是原因。
II. Scope of Impact
- Only affects returns/exchanges for outsourced (subcontracting) receipt notes.
- Regular purchase receipt returns, regular sales shipment returns, internal transfer returns are all normal.
- This is also why the problem remained hidden for so long before it was discovered—the business had been running ordinary documents for a long time first.
III. Troubleshooting Path (Four-Layer Exclusion Method)
Layer 1: Suspect the ITG18 custom module → Ruled out
Conducted a full audit of ITG18/:
| Inspection item | Result |
|---|---|
| Model inheriting stock.picking | Only 2: zd_version_number_picking_number (add related field), quality_control_stock_oca (add quality control button) |
| Rewrite location_dest_id / location_id | 0 occurrences (the only occurrence is mrp_workorder writing to stock.move.line, unrelated to picking) |
| Override _compute_location_id / _onchange_picking_type | 0 occurrences |
| View xpath hide/modify attribute location_dest_id | 0 occurrences (this string never appears anywhere in the entire ITG18 XML) |
| ir.model.fields override required/readonly | 0 occurrences |
| Returns/Exchanges / Returns / RMA Code | 0 occurrences (no results found in full Chinese and English search) |
| stock.return.picking / _create_return extension | 0 occurrences |
Conclusion: ITG18 is not to blame.
Layer 2: Suspect Data → Exclude
Actual testing on the online database:
-- Source document 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 ← completely normal -- Dirty data across entire database SELECT count(*) FILTER (WHERE location_id IS NULL), count(*) FILTER (WHERE location_dest_id IS NULL) FROM stock_picking; -- 0 | 0 -- Operation type 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 | Receiving | incoming | 8 | 15 | 5 -- 5 | Delivery Order | outgoing | 16 | 9 | 28 ← all normal
Conclusion: The data is fine.
Level 3: Local reproduction → caught mrp_subcontracting
Calling _create_return() locally in Odoo 18 with the same data, the exact same error, the same product Paint-00006:
ERROR: null value in column "location_dest_id" ... Failing row contains (31345, null, WH/OUT/10111, Return WH/IN/05334, ..., 13, null, 5, 676, ..., 28858, ...)
Continuing to break it down, we found:
w._prepare_picking_default_values_based_on(p) # → loc=13, dest=8 ✅ Correct w._prepare_picking_default_values() # → loc=13, dest=False ❌ Overwritten
What's the difference between the two? mrp_subcontracting overrides the method that takes no parameters:
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): # all are subcontracting moves
vals['location_dest_id'] = self.picking_id.partner_id \
.with_company(self.picking_id.company_id) \
.property_stock_subcontractor.id # ← empty means False
return vals
Layer 4: Why is this attribute empty → Differences in upgrade mechanisms
Print it out:
In ir_property: property_stock_subcontractor | res_id=NULL | company=1 | stock.location,269 ORM reads: partner.property_stock_subcontractor = EMPTY
The reason lies in the field storage changes in 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 changed `company_dependent` fields from the `ir_property` table to "a JSONB column on the model table + `ir.default` as a fallback".
Whereas mrp_subcontracting would have originally written this default value when creating a company:
addons/mrp_subcontracting/models/res_company.py
The provided segment is Python code with no Simplified Chinese text to translate, so it is returned unchanged:
def _create_subcontracting_location(self):
...
self.env['ir.default'].set(
"res.partner", "property_stock_subcontractor",
subcontracting_location.id, company_id=company.id,
)
This method only executes when a new company is created. It does not rerun when upgrading an existing company, and information from the v14 era is still lying in ir_property—Odoo 18 no longer reads it.
Online ir.default actual test:
property_stock_supplier | (company NULL) | 8 ✅ Present property_stock_customer | (company NULL) | 9 ✅ Present property_stock_subcontractor| —— None —— ❌ Missing
IV. Root Cause Chain (Three-Layer Stacking)
① Upgrade migration gap Odoo 18 changed company_dependent from ir_property to JSONB + ir.default ↓ property_stock_subcontractor's “company-level default value” was not migrated to ir.default ↓ ② Upstream code has no null fallback mrp_subcontracting on return unconditionally uses .id, empty recordset → False ↓ ③ Required field + database constraint location_dest_id required=True (NOT NULL) ↓ Return order INSERT writes NULL → NotNullViolation → UI reports “required field not set”
So “is it a problem with the upgrade script?”—you could say that, but to be precise:
It's not that the upgrade script "wrote the code wrong"; rather, the migration coverage is incomplete: Odoo 18's storage mechanism changed, and the company-level default rows with res_id IS NULL in ir_property were not converted into ir.default records (at least they were missed on property_stock_subcontractor). Coupled with mrp_subcontracting lacking a null fallback, it will only explode when both are true at the same time.
By the way, the upgrade also missed the `stock_location.is_subcontracting_location` flag (both subcontracting locations are NULL), indicating that this upgrade indeed has systematic omissions in data related to `mrp_subcontracting`.
5. Repair (Executed)
Add `ir.default` following the official Odoo `_create_subcontracting_location()` implementation:
# 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()
Meanwhile, add the missing mark:
UPDATE stock_location SET is_subcontracting_location = true WHERE id IN (269, 1013);
Using pure SQL to insert into ir_default is also fine, but you must restart the Odoo container to clear the ORM cache; using ir.default.set() is safer.
VI. Verification
Subcontracting Location = Physical Locations/Subcontracting Location Return Order: WH/OUT/10912 | WH/Temporary Receiving Area -> Physical Locations/Subcontracting Location
Before the fix, NotNullViolation occurred; after the fix, the return order is generated correctly, and the target location falls in the outsourced location.
The failed INSERT rolled back the entire transaction, there is no dirty data that needs to be cleaned up.
VII. Experience and Prevention
1. Must-check after upgrade: default values of company_dependent fields
-- Company-level default values that exist in v14 but not in 18 ("lost" list)
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;
This time, it was also found that res.partner.property_account_payable_id / property_account_receivable_id only have default values for Company 1, while Company 2 (ITG-HK) is missing them—if invoicing and accounting are to be done over there, it will blow up sooner or later, so it is recommended to add them as well.
2. In a multi-company environment, verify each company one by one
Fields like property_* are company_dependent, and their default values are also stored per company. Testing only Company 1 does not mean Company 2 is fine.
3. Add one item to the upgrade acceptance checklist
Go through all three return paths: 「Outsourced procurement receipt → return / return or exchange」「Purchase receipt → return」「Sales shipment → return」.
This type of problem is only exposed on specific business paths; running through the main process once will not reveal it at all.
4. Add fallback at the code level (optional)
Add a small module in ITG18 that falls back when the attribute is empty, and never write False into the required field:
# 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 dependencies: ['mrp_subcontracting', 'stock'].
5. File an upstream issue with Odoo
When property_stock_subcontractor.id is empty, it should fall back to company.subcontracting_location_id, rather than directly writing False into a field with required=True. This is an upstream robustness defect.
VIII. Key File Index
| File | Effect |
|---|---|
| addons/mrp_subcontracting/wizard/stock_picking_return.py:10-14 | Direct trigger point: Forced change of target location during returns |
| addons/mrp_subcontracting/models/res_company.py | The correct way to officially supplement default values (ir.default.set) |
| addons/stock/wizard/stock_picking_return.py:137-153 | Core return vals construction (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 definition (computed / store / required) |
| addons/stock/models/stock_picking.py:950-964 | _compute_location_id (skip directly when return_id exists) |
| odoo/fields.py:774 / 785-790 | Odoo 18 company_dependent → JSONB + ir.default fallback |
| odoo/service/model.py:100-112 | NotNullViolation → human-readable error message |
One-sentence summary: This is not a problem with custom modules, nor a data issue, but rather during the Odoo 14→18 upgrade, the company_dependent default value (property_stock_subcontractor) of mrp_subcontracting was not migrated to ir.default, combined with the upstream return wizard lacking a null-value fallback, which ultimately wrote NULL into a required field.
