Odoo 14 → 18 Upgrade Pitfall Log: Complete Troubleshooting of the “location_dest_id not set” Error in Subcontracting Receipt “Return/Exchange”


  • 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:

ColumnvalueMeaning
nameWH/OUT/10911The newly created one is an outbound order (return order).
originReturn WH/IN/06572Return of WH/IN/06572
return_id34484Source document = that receiving note
picking_type_id5Delivery Note (outgoing)
location_id15Source document's target location (WH/Temporary Receiving Area) ✓
location_dest_idNo 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 itemResult
Model inheriting stock.pickingOnly 2: zd_version_number_picking_number (add related field), quality_control_stock_oca (add quality control button)
Rewrite location_dest_id / location_id0 occurrences (the only occurrence is mrp_workorder writing to stock.move.line, unrelated to picking)
Override _compute_location_id / _onchange_picking_type0 occurrences
View xpath hide/modify attribute location_dest_id0 occurrences (this string never appears anywhere in the entire ITG18 XML)
ir.model.fields override required/readonly0 occurrences
Returns/Exchanges / Returns / RMA Code0 occurrences (no results found in full Chinese and English search)
stock.return.picking / _create_return extension0 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

FileEffect
addons/mrp_subcontracting/wizard/stock_picking_return.py:10-14Direct trigger point: Forced change of target location during returns
addons/mrp_subcontracting/models/res_company.pyThe correct way to officially supplement default values (ir.default.set)
addons/stock/wizard/stock_picking_return.py:137-153Core 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-613location_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-790Odoo 18 company_dependent → JSONB + ir.default fallback
odoo/service/model.py:100-112NotNullViolation → 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.

关于我们

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

扫一扫获取顾问以及手册

归档
Sign in to leave a comment
Odoo Recruitment Process Guide: A Full-Cycle Closed Loop from Talent Sourcing to Employee Onboarding
Modern enterprises commonly face challenges in the recruitment process such as cumbersome procedures, low communication efficiency, and scattered candidate data.