stock_account: avg_cost/total_value vary with allowed_company_ids but don't declare it, so the ORM cache serves the wrong company scope
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 78/100
Research direction
Start in addons/stock_account/models/product.py at _compute_value and its api.depends_context declaration. Reproduce the two allowed_company_ids reads in an odoo-bin shell, then compare the existing declarations in addons/account/models/product.py and addons/account/models/uom_uom.py. Done means each company scope returns its own value instead of sharing one ORM cache entry.
Written by the indexing model from the issue text.
Description
Impacted versions
19.0 (verified at a0de210abc4) and master (verified at 2a0c84b3088). The declaration and the compute are identical on both; line references below are 19.0.
Steps to Reproduce
product.product.avg_cost / total_value are computed by _compute_value in addons/stock_account/models/product.py, declared:
@api.depends_context('to_date', 'company', 'warehouse_id') # L179
The compute does not read env.company alone. It iterates every allowed company and converts into the active one:
for company in self.env.companies: # L201
...
product.total_value = sum(
c.currency_id._convert(
total_value_by_company_id[c.id].get(product.id, 0),
self.env.company.currency_id,
)
for c in self.env.companies # L276
)
self.env.companies derives from the allowed_company_ids context key, which is not declared. Env.cache_key reduces the declared 'company' key to the active company's id only:
def get(key, get_context=self.context.get):
if key == 'company':
return self.company.id # odoo/orm/environments.py L474
So two reads with the same active company but different allowed_company_ids produce different values under one cache key, and whichever runs first is served to the other for the rest of the transaction.
Reproduction, core models only, run in odoo-bin shell on a database with stock_account installed:
company_a = env.company
company_b = env["res.company"].create({"name": "Repro Co B"})
wh_a = env["stock.warehouse"].search([("company_id", "=", company_a.id)], limit=1)
wh_b = (env["stock.warehouse"].with_company(company_b)
.with_context(allowed_company_ids=company_b.ids)
.create({"name": "Repro B", "code": "RPB", "company_id": company_b.id}))
product = env["product.product"].create({
"name": "Repro Product",
"is_storable": True,
"categ_id": env.ref("product.product_category_goods").id,
})
product.categ_id.property_cost_method = "average"
product.with_company(company_a).standard_price = 10
product.with_company(company_b).standard_price = 40
for company, wh in ((company_a, wh_a), (company_b, wh_b)):
env["stock.quant"].with_company(company).with_context(
inventory_mode=True, allowed_company_ids=company.ids
).create({
"product_id": product.id,
"inventory_quantity": 10,
"location_id": wh.lot_stock_id.id,
}).action_apply_inventory()
env.flush_all()
both, only_a = (company_a + company_b).ids, company_a.ids
def read(allowed):
# env.company is A in every read; only allowed_company_ids differs
return product.with_company(company_a).with_context(
allowed_company_ids=allowed).avg_cost
product.invalidate_recordset(["avg_cost"])
print("narrow first:", read(only_a), read(both))
product.invalidate_recordset(["avg_cost"])
print("wide first: ", read(both), read(only_a))
field = product._fields["avg_cost"]
for allowed in (only_a, both):
e = env(context=dict(env.context, allowed_company_ids=allowed))
print("cache key:", e.cache_key(field))
10 units at 10.00 in company A, 10 units at 40.00 in company B. Read alone, each scope is correct: allowed=[A] gives 10.0, allowed=[A, B] gives 25.0.
Current behavior
narrow first: 10.0 10.0 <-- second read wanted 25.0
wide first: 25.0 25.0 <-- second read wanted 10.0
cache key: (None, 1, None)
cache key: (None, 1, None)
Both scopes share the cache key (to_date, company.id, warehouse_id) = (None, 1, None), so the first read wins. The direction that worries us more is the first one: a narrow single-company read poisons a subsequent multi-company read, so code that is scrupulous about scoping its own valuation can still corrupt an unrelated consumer later in the same transaction.
Expected behavior
Each read returns the value for its own company scope, as it does when the cache is cleared in between:
allowed=[A] -> 10.0
allowed=[A, B] -> 25.0
Suggested fix
Declare the key the compute actually depends on:
@api.depends_context('to_date', 'company', 'allowed_company_ids', 'warehouse_id')
Core already declares allowed_company_ids this way in about ten places, e.g. addons/account/models/product.py L101 and addons/account/models/uom_uom.py L43.
company_currency_id, assigned by the same compute from self.env.company.currency_id, is unaffected by the extra key but shares the declaration.
Possibly related: #256741 removed store=True from stock.lot.avg_cost on the grounds that "the compute method is company-context-dependent". This is the same premise reaching the ORM cache rather than a stored column.
Log Output / Support Ticket
n/a — reproduced from source, no ticket.
- Dominant language
- Python
- Stars
- 54.5k
- Forks
- 33.8k
- PR merge metrics
- No merged PRs in 30d
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from odoo/odoo
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
-
[19.0] web: notebook 'white-space: nowrap' on the notebook root breaks page-level button layout Open
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100