Skip to content

Conversation

@danielalvaro1999
Copy link

@danielalvaro1999 danielalvaro1999 commented Dec 24, 2025

[FIX] mrp_multi_level: Fix MRP wizard logic (PR #1683)

Why

Previously, the MRP Multi Level procurement wizard did not reliably allow users to explicitly choose the procurement method (manufacture/buy/pull/push/pull_push): the actual rule/action used at execution time could be driven by routes/rules and end up different from what the user intended.

This change makes the resulting procurement behavior consistent with the user’s explicit selection.

What changed

1) Wizard: propagate the selected method + keep better context

In mrp_multi_level/wizards/mrp_inventory_procure.py:

  • Each wizard line now carries extra context to behave correctly depending on where the wizard was opened from:
    • source_context (planned order vs inventory)
    • original_qty (used to keep quantities stable when changing the UoM)
  • The selected procurement method is propagated through values["mrp_action"] so the downstream rule selection can use it.

2) New file: procurement group override (new flow)

Added mrp_multi_level/models/procurement_group.py:

  • Override procurement.group._get_rule() to respect mrp_action when it is provided in values.
  • The search is deterministic and safer for multi-company setups:
    • Filters by company_id in [company, False]
    • Uses order="sequence" to pick the highest priority rule
    • Tries destination location first, then falls back to warehouse

3) Multi-currency POs: avoid cross-currency draft reuse

In mrp_multi_level/models/stock_rule.py:

  • Include currency_id in the draft PO lookup domain when present in values.
  • Force the PO currency when currency_id is provided (supports both a single dict and grouped values).

4) UI: make the method clearly editable

In mrp_multi_level/wizards/mrp_inventory_procure_views.xml:

  • Keep wizard lines editable (editable="top").
  • Show vendor/currency columns only when the line action is buy.
  • Add an info message encouraging users to adjust the procurement method per line before executing.

5) Planned orders: clearer action + direct entrypoint

In mrp_multi_level/views/mrp_planned_order_views.xml:

  • Improve mrp_action visibility using a badge + decorations.
  • Add a “Create Procurement” button on the planned order form to open the wizard.

6) Change supply_method string:

  • Changed the string to “default supply method” instead of “supply method”, to avoid confusion between the mrp_action field and supply_method.ange stringo to Default supply method

7) Tests

In mrp_multi_level/tests/test_mrp_multi_level.py:

  • Added/updated tests covering the new wizard flows and the fact that _get_rule() respects mrp_action.

Modified files

  • mrp_multi_level/models/__init__.py
  • mrp_multi_level/models/procurement_group.py (new)
  • mrp_multi_level/models/mrp_inventory.py
  • mrp_multi_level/models/stock_rule.py
  • mrp_multi_level/models/product_mrp_area.py
  • mrp_multi_level/wizards/mrp_inventory_procure.py
  • mrp_multi_level/wizards/mrp_inventory_procure_views.xml
  • mrp_multi_level/views/mrp_planned_order_views.xml
  • mrp_multi_level/tests/test_mrp_multi_level.py

@OCA-git-bot
Copy link
Contributor

Hi @ChrisOForgeFlow, @LoisRForgeFlow, @JordiBForgeFlow,
some modules you are maintaining are being modified, check this out!

Copy link

@AdrianaSaiz AdrianaSaiz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Changes Summary:

  1. Editable mrp_action field: Users can now change the procurement method
    (buy, manufacture, pull, pull_push) before executing. The selected method
    is respected via ProcurementGroup._get_rule() override in stock_rule.py.

  2. Context-aware quantity handling: Added 'source_context' and 'original_qty'
    fields to prevent quantity reset when opening wizard from planned orders.

    • From inventory: recalculates qty from mrp_inventory.to_procure
    • From planned order: preserves original planned quantity
  3. Company and currency support: Added company_id and currency_id to
    procurement values to avoid errors during PO/MO creation and to support
    multi-currency purchases.

  4. Vendor pre-fill for purchases: When mrp_action='buy', the wizard now
    pre-fills supplier_id and currency_id from product's seller info.

  5. Tests added (test_mrp_multi_level.py):

    • test_26: Override to 'buy' creates PO
    • test_31: _get_rule respects mrp_action parameter
    • test_32: UoM onchange fallback to original_qty
    • test_33: Override to 'manufacture' creates MO
    • test_34: Override to 'pull' creates picking
    • test_35: Override to 'pull_push' creates picking
      Note: 'push' action not tested as it works on existing moves, not procurements.

Comment on lines 287 to 300
domain = [
("action", "=", self.mrp_action),
("location_dest_id", "=", location.id),
]

rule = self.env["stock.rule"].search(domain, limit=1)

if not rule:
domain2 = [
("action", "=", self.mrp_action),
("warehouse_id", "=", warehouse.id),
]
rule = self.env["stock.rule"].search(domain2, limit=1)
return rule

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
domain = [
("action", "=", self.mrp_action),
("location_dest_id", "=", location.id),
]
rule = self.env["stock.rule"].search(domain, limit=1)
if not rule:
domain2 = [
("action", "=", self.mrp_action),
("warehouse_id", "=", warehouse.id),
]
rule = self.env["stock.rule"].search(domain2, limit=1)
return rule
company = self.warehouse_id.company_id or self.env.company
domain = [
("action", "=", self.mrp_action),
("location_dest_id", "=", location.id),
("company_id", "in", [company.id, False]),
]
rule = self.env["stock.rule"].search(domain, order="sequence", limit=1)
if not rule:
domain2 = [
("action", "=", self.mrp_action),
("warehouse_id", "=", self.warehouse_id.id),
("company_id", "in", [company.id, False]),
]
rule = self.env["stock.rule"].search(domain2, order="sequence", limit=1)

Si hay varias reglas con el mismo action (ej: dos reglas "buy"), limit=1 sin order puede devolver cualquiera. Mejor añadir order="sequence" para que nos devuelva la de mayor prioridad.
En entorno multi-company podría devolver una regla de otra empresa. Por eso habría que añadir filtro por company_id.

Comment on lines 303 to 283
company = self.warehouse_id.company_id or self.env.company
currency = company.currency_id

res = {
"date_planned": self.date_planned,
"warehouse_id": self.warehouse_id,
"group_id": group,
"planned_order_id": self.planned_order_id.id,
"company_id": company,
"currency_id": currency.id,
}
should_add_supplier = (self.mrp_action == "buy") or (
not self.mrp_action and self.supply_method == "buy"
)

if should_add_supplier:
qty = self.qty or 0.0
uom = self.uom_id or self.product_id.uom_id
supplier_info = self.product_id._select_seller(
quantity=qty,
date=self.date_planned,
uom_id=uom,
partner_id=self.supplier_id,
)
if not supplier_info:
supplier_info = self.product_id.seller_ids.filtered(
lambda s: (not s.company_id or s.company_id == company)
).sorted(lambda s: (s.sequence, s.min_qty, s.price))[:1]

if supplier_info:
res["supplier_id"] = supplier_info.partner_id.id
res["currency_id"] = (
supplier_info.currency_id or company.currency_id
).id
else:
res["currency_id"] = company.currency_id.id

if hasattr(self, "mrp_action") and self.mrp_action:
res["mrp_action"] = self.mrp_action

rule = self._get_procurement_rule()
if rule:
res["rule_id"] = rule.id
return res

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
company = self.warehouse_id.company_id or self.env.company
currency = company.currency_id
res = {
"date_planned": self.date_planned,
"warehouse_id": self.warehouse_id,
"group_id": group,
"planned_order_id": self.planned_order_id.id,
"company_id": company,
"currency_id": currency.id,
}
should_add_supplier = (self.mrp_action == "buy") or (
not self.mrp_action and self.supply_method == "buy"
)
if should_add_supplier:
qty = self.qty or 0.0
uom = self.uom_id or self.product_id.uom_id
supplier_info = self.product_id._select_seller(
quantity=qty,
date=self.date_planned,
uom_id=uom,
partner_id=self.supplier_id,
)
if not supplier_info:
supplier_info = self.product_id.seller_ids.filtered(
lambda s: (not s.company_id or s.company_id == company)
).sorted(lambda s: (s.sequence, s.min_qty, s.price))[:1]
if supplier_info:
res["supplier_id"] = supplier_info.partner_id.id
res["currency_id"] = (
supplier_info.currency_id or company.currency_id
).id
else:
res["currency_id"] = company.currency_id.id
if hasattr(self, "mrp_action") and self.mrp_action:
res["mrp_action"] = self.mrp_action
rule = self._get_procurement_rule()
if rule:
res["rule_id"] = rule.id
return res
company = self.warehouse_id.company_id or self.env.company
currency = company.currency_id
res = {
"date_planned": self.date_planned,
"warehouse_id": self.warehouse_id,
"group_id": group,
"planned_order_id": self.planned_order_id.id,
"company_id": company,
"currency_id": currency.id,
}
# Añadir mrp_action para que _get_rule lo use
if self.mrp_action:
res["mrp_action"] = self.mrp_action
should_add_supplier = (self.mrp_action == "buy") or (
not self.mrp_action and self.supply_method == "buy"
)
if should_add_supplier:
qty = self.qty or 0.0
uom = self.uom_id or self.product_id.uom_id
supplier_info = self.product_id._select_seller(
quantity=qty,
date=self.date_planned,
uom_id=uom,
partner_id=self.supplier_id,
)
if not supplier_info:
supplier_info = self.product_id.seller_ids.filtered(
lambda s: (not s.company_id or s.company_id == company)
).sorted(lambda s: (s.sequence, s.min_qty, s.price))[:1]
if supplier_info:
res["supplierinfo_id"] = supplier_info
res["currency_id"] = (
supplier_info.currency_id or company.currency_id
).id
else:
res["currency_id"] = company.currency_id.id
return res

En este punto la llamada a _get_procurement_rule() y la asignación de rule_id en los values no son necesarias. Solo necesitamots añadir mrp_action a los values para que _get_rule lo use.
Ya no necesitamos buscar la regla aquí ni pasarla en los values. Con la herencia de _get_rule, la regla correcta se selecciona automáticamente dentro del flujo de procurement cuando lee el mrp_action. Solo tenemos que pasarle el mrp_action y Odoo hace el resto.

Comment on lines 278 to 300
def _get_procurement_rule(self):
self.ensure_one()

if not self.mrp_action or self.mrp_action == "none":
return False

location = self.location_id
warehouse = self.warehouse_id

domain = [
("action", "=", self.mrp_action),
("location_dest_id", "=", location.id),
]

rule = self.env["stock.rule"].search(domain, limit=1)

if not rule:
domain2 = [
("action", "=", self.mrp_action),
("warehouse_id", "=", warehouse.id),
]
rule = self.env["stock.rule"].search(domain2, limit=1)
return rule

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _get_procurement_rule(self):
self.ensure_one()
if not self.mrp_action or self.mrp_action == "none":
return False
location = self.location_id
warehouse = self.warehouse_id
domain = [
("action", "=", self.mrp_action),
("location_dest_id", "=", location.id),
]
rule = self.env["stock.rule"].search(domain, limit=1)
if not rule:
domain2 = [
("action", "=", self.mrp_action),
("warehouse_id", "=", warehouse.id),
]
rule = self.env["stock.rule"].search(domain2, limit=1)
return rule

Esta método hay que eliminarlo, ya no es necesario porque la lógica va a estar en _get_rule del procurement group.

Comment on lines 1078 to 1153
def test_31_procure_wizard_forced_rule_pull_calls_run_pull(self):
"""Covers the branch where a forced rule triggers `_run_pull`."""
mrp_inv = self.mrp_inventory_obj.search(
[("product_mrp_area_id.product_id", "=", self.fp_1.id)], limit=1
)
self.assertTrue(mrp_inv)

wiz = self.mrp_inventory_procure_wiz.with_context(
active_model="mrp.inventory",
active_ids=mrp_inv.ids,
active_id=mrp_inv.id,
).create({})
self.assertTrue(wiz.item_ids)
item = wiz.item_ids[0]
item.qty = 1.0

route = self.env["stock.route"].create(
{
"name": "Test route (mrp_multi_level)",
"product_selectable": False,
"warehouse_selectable": False,
"company_id": self.company.id,
}
)

picking_type = item.warehouse_id.in_type_id
self.assertTrue(picking_type)

rule = self.env["stock.rule"].create(
{
"name": "Test pull rule (mrp_multi_level)",
"route_id": route.id,
"action": "pull",
"location_src_id": item.location_id.id,
"location_dest_id": item.location_id.id,
"picking_type_id": picking_type.id,
"company_id": self.company.id,
}
)

calls = {"count": 0, "args": None}

ItemModel = type(item)
RuleModel = type(rule)
PGModel = type(self.env["procurement.group"])

original_prepare = ItemModel._prepare_procurement_values
original_run_pull = RuleModel._run_pull
original_pg_run = PGModel.run

def _fake_prepare_procurement_values(_self):
vals = original_prepare(_self)
vals["rule_id"] = rule.id
return vals

def _fake_run_pull(_self, procurements):
calls["count"] += 1
calls["args"] = procurements
return True

def _fake_pg_run(_self, procurements):
raise AssertionError("Shouldn't call procurement.group.run()")

try:
ItemModel._prepare_procurement_values = _fake_prepare_procurement_values
RuleModel._run_pull = _fake_run_pull
PGModel.run = _fake_pg_run
wiz.make_procurement()
finally:
ItemModel._prepare_procurement_values = original_prepare
RuleModel._run_pull = original_run_pull
PGModel.run = original_pg_run

self.assertEqual(calls["count"], 1)
self.assertTrue(calls["args"])
self.assertEqual(calls["args"][0][1].id, rule.id)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_31_procure_wizard_forced_rule_pull_calls_run_pull(self):
"""Covers the branch where a forced rule triggers `_run_pull`."""
mrp_inv = self.mrp_inventory_obj.search(
[("product_mrp_area_id.product_id", "=", self.fp_1.id)], limit=1
)
self.assertTrue(mrp_inv)
wiz = self.mrp_inventory_procure_wiz.with_context(
active_model="mrp.inventory",
active_ids=mrp_inv.ids,
active_id=mrp_inv.id,
).create({})
self.assertTrue(wiz.item_ids)
item = wiz.item_ids[0]
item.qty = 1.0
route = self.env["stock.route"].create(
{
"name": "Test route (mrp_multi_level)",
"product_selectable": False,
"warehouse_selectable": False,
"company_id": self.company.id,
}
)
picking_type = item.warehouse_id.in_type_id
self.assertTrue(picking_type)
rule = self.env["stock.rule"].create(
{
"name": "Test pull rule (mrp_multi_level)",
"route_id": route.id,
"action": "pull",
"location_src_id": item.location_id.id,
"location_dest_id": item.location_id.id,
"picking_type_id": picking_type.id,
"company_id": self.company.id,
}
)
calls = {"count": 0, "args": None}
ItemModel = type(item)
RuleModel = type(rule)
PGModel = type(self.env["procurement.group"])
original_prepare = ItemModel._prepare_procurement_values
original_run_pull = RuleModel._run_pull
original_pg_run = PGModel.run
def _fake_prepare_procurement_values(_self):
vals = original_prepare(_self)
vals["rule_id"] = rule.id
return vals
def _fake_run_pull(_self, procurements):
calls["count"] += 1
calls["args"] = procurements
return True
def _fake_pg_run(_self, procurements):
raise AssertionError("Shouldn't call procurement.group.run()")
try:
ItemModel._prepare_procurement_values = _fake_prepare_procurement_values
RuleModel._run_pull = _fake_run_pull
PGModel.run = _fake_pg_run
wiz.make_procurement()
finally:
ItemModel._prepare_procurement_values = original_prepare
RuleModel._run_pull = original_run_pull
PGModel.run = original_pg_run
self.assertEqual(calls["count"], 1)
self.assertTrue(calls["args"])
self.assertEqual(calls["args"][0][1].id, rule.id)
def test_31_get_rule_respects_mrp_action(self):
"""Test that _get_rule returns rule matching mrp_action when provided."""
mrp_inv = self.mrp_inventory_obj.search(
[("product_mrp_area_id.product_id", "=", self.fp_1.id)], limit=1
)
self.assertTrue(mrp_inv)
wiz = self.mrp_inventory_procure_wiz.with_context(
active_model="mrp.inventory",
active_ids=mrp_inv.ids,
active_id=mrp_inv.id,
).create({})
self.assertTrue(wiz.item_ids)
item = wiz.item_ids[0]
route = self.env["stock.route"].create(
{
"name": "Test route (mrp_multi_level)",
"product_selectable": False,
"warehouse_selectable": False,
"company_id": self.company.id,
}
)
picking_type = item.warehouse_id.in_type_id
pull_rule = self.env["stock.rule"].create(
{
"name": "Test pull rule (mrp_multi_level)",
"route_id": route.id,
"action": "pull",
"location_src_id": self.supplier_location.id,
"location_dest_id": item.location_id.id,
"picking_type_id": picking_type.id,
"company_id": self.company.id,
}
)
# Veriffy _get_rule returns pull rule when mrp_action='pull'
pg = self.env["procurement.group"]
values = {
"mrp_action": "pull",
"company_id": self.company,
"warehouse_id": item.warehouse_id,
}
found_rule = pg._get_rule(item.product_id, item.location_id, values)
self.assertEqual(found_rule.id, pull_rule.id)
self.assertEqual(found_rule.action, "pull")

Reescribir test para verificar el nuevo comportamiento: que _get_rule respeta el mrp_action pasado en los values y devuelve la regla correcta. El test anterior verificaba que se llamaba directamente a _run_pull, lo cual ya no aplica.

@danielalvaro1999 danielalvaro1999 marked this pull request as ready for review December 29, 2025 16:10
Comment on lines 8 to 43
def _get_rule(self, product_id, location_id, values):
"""Override to respect mrp_action from MRP Multi Level wizard."""
mrp_action = values.get("mrp_action")
if mrp_action and mrp_action not in ("none", False):
company = values.get("company_id", self.env.company)
if hasattr(company, "id"):
company_id = company.id
else:
company_id = company
rule = self.env["stock.rule"].search(
[
("action", "=", mrp_action),
("location_dest_id", "=", location_id.id),
("company_id", "in", [company_id, False]),
],
order="sequence",
limit=1,
)
if not rule:
warehouse = values.get("warehouse_id")
if warehouse:
warehouse_id = (
warehouse.id if hasattr(warehouse, "id") else warehouse
)
rule = self.env["stock.rule"].search(
[
("action", "=", mrp_action),
("warehouse_id", "=", warehouse_id),
("company_id", "in", [company_id, False]),
],
order="sequence",
limit=1,
)
if rule:
return rule
return super()._get_rule(product_id, location_id, values)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _get_rule(self, product_id, location_id, values):
"""Override to respect mrp_action from MRP Multi Level wizard."""
mrp_action = values.get("mrp_action")
if mrp_action and mrp_action not in ("none", False):
company = values.get("company_id", self.env.company)
if hasattr(company, "id"):
company_id = company.id
else:
company_id = company
rule = self.env["stock.rule"].search(
[
("action", "=", mrp_action),
("location_dest_id", "=", location_id.id),
("company_id", "in", [company_id, False]),
],
order="sequence",
limit=1,
)
if not rule:
warehouse = values.get("warehouse_id")
if warehouse:
warehouse_id = (
warehouse.id if hasattr(warehouse, "id") else warehouse
)
rule = self.env["stock.rule"].search(
[
("action", "=", mrp_action),
("warehouse_id", "=", warehouse_id),
("company_id", "in", [company_id, False]),
],
order="sequence",
limit=1,
)
if rule:
return rule
return super()._get_rule(product_id, location_id, values)
def _get_rule(self, product_id, location_id, values):
"""Override to respect mrp_action from MRP Multi Level wizard."""
mrp_action = values.get("mrp_action")
# If there is no valid mrp_action (None, False, 'none', ''), delegate to super
if not mrp_action or mrp_action in ("none", False):
return super()._get_rule(product_id, location_id, values)
# Normalize company (accept record or id)
company = values.get("company_id", self.env.company)
company_id = company.id if hasattr(company, "id") else company
domain = [
("action", "=", mrp_action),
("company_id", "in", [company_id, False]),
]
rule = self.env["stock.rule"].search(
domain + [("location_dest_id", "=", location_id.id)],
order="sequence",
limit=1,
)
if not rule:
warehouse = values.get("warehouse_id")
if warehouse:
warehouse_id = (
warehouse.id if hasattr(warehouse, "id") else warehouse
)
rule = self.env["stock.rule"].search(
domain + [("warehouse_id", "=", warehouse_id)],
order="sequence",
limit=1,
)
return rule or super()._get_rule(product_id, location_id, values)

@danielalvaro1999 danielalvaro1999 force-pushed the 16.0-fix-mrp_multi_level branch 7 times, most recently from 58a656a to 395fc70 Compare January 9, 2026 12:06
…antity reset, and missing company/currency data
@danielalvaro1999 danielalvaro1999 force-pushed the 16.0-fix-mrp_multi_level branch from 395fc70 to 204d148 Compare January 9, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants