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

Open Beginner friendly
#283,301 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
python
Domain
backend

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from odoo/odoo

All issues in odoo/odoo

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.