Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[14.0][IMP/ADD] stock_product_variant_mto, sale_stock_mto_as_mts_orderpoint_product_variant: Handle mto route at variant level #1461

Draft
wants to merge 6 commits into
base: 14.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion sale_stock_mto_as_mts_orderpoint/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from . import product
from . import product_template
from . import product_product
from . import sale_order
from . import stock_move
from . import stock_warehouse
15 changes: 15 additions & 0 deletions sale_stock_mto_as_mts_orderpoint/models/product_product.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2023 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import models

class ProductProduct(models.Model):
_inherit = "product.product"

def _variant_is_mto(self):
self.ensure_one()
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
return mto_route in self.route_ids

def _variant_is_not_mto(self):
return not self._variant_is_mto()
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ def write(self, vals):
products_to_update._archive_orderpoints_on_mto_removal()
return res

def _template_is_mto(self):
self.ensure_one()
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
return mto_route in self.route_ids

def _template_is_not_mto(self):
self.ensure_one()
return not self._template_is_mto()

def _filter_mto_products(self, mto=True):
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
if mto:
func = lambda p: mto_route in p.route_ids # noqa
return self.filtered(lambda t: t._template_is_mto())
else:
func = lambda p: mto_route not in p.route_ids # noqa
return self.filtered(func)
return self.filtered(lambda t: t._template_is_not_mto())

def _get_orderpoints_to_archive_domain(self):
warehouses = self.env["stock.warehouse"].search([])
Expand Down
2 changes: 1 addition & 1 deletion sale_stock_mto_as_mts_orderpoint/models/sale_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def _run_orderpoints_for_mto_products(self):
for delivery_move in delivery_moves:
if (
not delivery_move.is_from_mto_route
and mto_route not in delivery_move.product_id.route_ids
or line.product_id._variant_is_not_mto()
):
continue
if not delivery_move.warehouse_id.mto_as_mts:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
19 changes: 19 additions & 0 deletions sale_stock_mto_as_mts_orderpoint_product_variant/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2024 Camptocamp SA

Choose a reason for hiding this comment

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

Inconsistent years

Suggested change
# Copyright 2024 Camptocamp SA
# Copyright 2025 Camptocamp SA

# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

{
mmequignon marked this conversation as resolved.
Show resolved Hide resolved
"name": "Sale Stock MTO as MTS Orderpoint Product Variant",
"version": "14.0.1.0.0",
"development_status": "Alpha",
"category": "Operations/Inventory/Delivery",
"website": "https://github.com/OCA/stock-logistics-workflow",
"author": "Camptocamp, Odoo Community Association (OCA)",
"maintainers": ["mmequignon"],
"license": "AGPL-3",
"installable": True,
"auto_install": True,
"depends": [
"sale_stock_mto_as_mts_orderpoint",
"stock_product_variant_mto",
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import product_product
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2023 Camptocamp SA

Choose a reason for hiding this comment

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

Suggested change
# Copyright 2023 Camptocamp SA
# Copyright 2025 Camptocamp SA

# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import api, models

class ProductProduct(models.Model):
_inherit = "product.product"

def _variant_is_mto(self):
self.ensure_one()
return self.is_mto

def _inverse_is_mto(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is this defined on the field? I don't think this is called.
Check you have a unit test for this (archiving orderpoint when variant is not MTO anymore)

res = super()._inverse_is_mto()
self._archive_orderpoints_on_mto_removal()
return res

@api.depends("product_tmpl_id.route_ids")
def _compute_is_mto(self):
# Archive orderpoints when variant becomes not mto
res = super()._compute_is_mto()
self._archive_orderpoints_on_mto_removal()
return res

def _get_orderpoints_to_archive_domain(self):
# Orderpoints to archive are those where

Choose a reason for hiding this comment

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

Incomplete comment?

warehouses = self.env["stock.warehouse"].search([])
locations = warehouses._get_locations_for_mto_orderpoints()
return [
("product_id", "in", self.ids),
("product_min_qty", "=", 0.0),
("product_max_qty", "=", 0.0),
("location_id", "in", locations.ids),
("product_id.is_mto", "=", False),
]

def _archive_orderpoints_on_mto_removal(self):
domain = self._get_orderpoints_to_archive_domain()
ops = self.env["stock.warehouse.orderpoint"].search(domain)
if ops:
ops.write({"active": False})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Matthieu Méquignon <[email protected]>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This module extends the `sale_stock_mto_as_mts_orderpoint` module,
in order to handle the orderpoints at variant level.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_mto_as_mts_variant
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Copyright 2023 Camptocamp SA

Choose a reason for hiding this comment

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

Suggested change
# Copyright 2023 Camptocamp SA
# Copyright 2025 Camptocamp SA

# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo.tests import Form
from odoo.addons.stock_product_variant_mto.tests.common import TestMTOVariantCommon


class TestMtoAsMtsVariant(TestMTOVariantCommon):

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env.ref("base.res_partner_2")
cls.vendor_partner = cls.env.ref("base.res_partner_12")
cls.env["product.supplierinfo"].create(
[
{
"name": cls.vendor_partner.id,
"product_tmpl_id": variant.product_tmpl_id.id,
"product_id": variant.id,
"min_qty": 1.0,
"price": 1.0,
}
for variant in cls.variants_pen
]
)
cls.warehouse = cls.env.ref("stock.warehouse0")

@classmethod
def setUpClassProduct(cls):
super().setUpClassProduct()
cls.buy_route = cls.env.ref("purchase_stock.route_warehouse0_buy")
cls.template_pen.write(
{"route_ids": [(6, 0, [cls.buy_route.id, cls.mto_route.id])]}
)

@classmethod
def _create_sale_order(cls, products):
sale_form = Form(cls.env["sale.order"])
sale_form.partner_id = cls.partner
sale_form.warehouse_id = cls.warehouse
for product in products:
with sale_form.order_line.new() as line_form:
line_form.product_id = product
line_form.product_uom_qty = 1
return sale_form.save()

def _get_orderpoint_for_products(self, products, archived=False):
orderpoint = self.env["stock.warehouse.orderpoint"]
if archived:
orderpoint = orderpoint.with_context(active_test=False)
return orderpoint.search(
[("product_id", "in", products.ids)]
)

def test_mto_as_mts_orderpoint(self):
template_pen = self.template_pen
black_pen = self.black_pen
blue_pen = self.blue_pen
red_pen = self.red_pen
green_pen = self.green_pen
order = self._create_sale_order(black_pen)
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertFalse(orderpoint)
order.action_confirm()
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertEqual(
orderpoint.location_id,
self.warehouse._get_locations_for_mto_orderpoints(),
)
self.assertAlmostEqual(orderpoint.product_min_qty, 0.0)
self.assertAlmostEqual(orderpoint.product_max_qty, 0.0)
# Setting the black pen to mto should drop its orderpoint
self.toggle_is_mto(black_pen)
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertFalse(orderpoint)
# Creating and confirming an order for variants should create
# an orderpoint for all variants but the black pen
order = self._create_sale_order(self.variants_pen)
order.action_confirm()
# black pen orderpoint is archived
self.assertFalse(self._get_orderpoint_for_products(black_pen))
self.assertTrue(self._get_orderpoint_for_products(black_pen, archived=True))
other_pens = red_pen | green_pen | blue_pen
self.assertEqual(
len(self._get_orderpoint_for_products(other_pens)), 3
)

def test_mtp_as_mts_orderpoint_product_no_mto(self):
template_pen = self.template_pen
black_pen = self.black_pen
variants_pen = self.variants_pen
# set everything to not mto
template_pen.route_ids = False
self.toggle_is_mto(variants_pen)
# then check that no orderpoint is created
order = self._create_sale_order(black_pen)
orderpoint = self.env["stock.warehouse.orderpoint"].search(
[("product_id", "=", black_pen.id)]
)
self.assertFalse(orderpoint)
order.action_confirm()
orderpoint = self.env["stock.warehouse.orderpoint"].search(
[("product_id", "=", black_pen.id)]
)
self.assertFalse(orderpoint)

def test_cancel_sale_order_orderpoint(self):
order = self._create_sale_order(self.variants_pen)
order.action_confirm()
order.action_cancel()
order.action_draft()
order.action_confirm()
self.assertEqual(order.state, "sale")

def test_confirm_mto_as_mts_sudo_needed(self):
"""Check access right needed to confirm sale.

A sale manager user with no right on inventory will raise an access
right error on confirmation.
This is the why of the sudo in `sale_stock_mto_as_mts_orderpoint`
"""
user = self.env.ref("base.user_demo")
sale_group = self.env.ref("sales_team.group_sale_manager")
sale_group.users = [(4, user.id)]
order = self._create_sale_order(self.variants_pen)
order.with_user(user).action_confirm()
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
6 changes: 6 additions & 0 deletions setup/stock_product_variant_mto/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
1 change: 1 addition & 0 deletions stock_product_variant_mto/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
18 changes: 18 additions & 0 deletions stock_product_variant_mto/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2023 Camptocamp SA

Choose a reason for hiding this comment

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

Suggested change
# Copyright 2023 Camptocamp SA
# Copyright 2025 Camptocamp SA

# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

{
"name": "Stock Product Variant MTO",
"summary": "Allow to individually set variants as MTO",
"version": "14.0.1.0.0",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/stock-workflow",
"author": "Camptocamp SA, Odoo Community Association (OCA)",
"maintainers": ["mmequignon"],
"license": "AGPL-3",
"installable": True,
"auto_install": False,
"depends": ["stock"],
"data": ["views/product_product.xml"],
}
2 changes: 2 additions & 0 deletions stock_product_variant_mto/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import product_product
from . import product_template
51 changes: 51 additions & 0 deletions stock_product_variant_mto/models/product_product.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright 2023 Camptocamp SA

Choose a reason for hiding this comment

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

Suggested change
# Copyright 2023 Camptocamp SA
# Copyright 2025 Camptocamp SA

# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import api, models, fields

IS_MTO_HELP = """
Check or Uncheck this field to enable the Make To Order on the variant,
independantly from its template configuration.\n

Choose a reason for hiding this comment

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

Suggested change
independantly from its template configuration.\n
independently from its template configuration.\n

Please note that activating or deactivating Make To Order on the template,
will reset this setting on its variants.
"""

class ProductProduct(models.Model):
_inherit = "product.product"

is_mto = fields.Boolean(
string="Variant is MTO",
compute="_compute_is_mto",
store=True,
readonly=False,
help=IS_MTO_HELP,
)

route_ids = fields.Many2many(
Copy link
Member

@TDu TDu Feb 11, 2025

Choose a reason for hiding this comment

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

If find having this field on product.product for handling the feature of this module worrisome and could be conflicting with other modules or implementation...

"stock.location.route",
compute="_compute_route_ids",
domain="[('product_selectable', '=', True)]",
store=False
)

def _compute_is_mto(self):
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
for product in self:
if not mto_route:
product.is_mto = False
continue
product.is_mto = mto_route in product.product_tmpl_id.route_ids

@api.depends("is_mto", "product_tmpl_id.route_ids")
def _compute_route_ids(self):
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
for product in self:
if mto_route and mto_route in product.product_tmpl_id.route_ids:
if not product.is_mto:
product.route_ids = product.product_tmpl_id.route_ids - mto_route
continue
else:
if mto_route and product.is_mto:
product.route_ids = product.product_tmpl_id.route_ids + mto_route
continue
product.route_ids = product.product_tmpl_id.route_ids
43 changes: 43 additions & 0 deletions stock_product_variant_mto/models/product_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Copyright 2023 Camptocamp SA

Choose a reason for hiding this comment

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

Suggested change
# Copyright 2023 Camptocamp SA
# Copyright 2025 Camptocamp SA

# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import _, api, models, fields

class ProductTemplate(models.Model):
_inherit = "product.template"

def write(self, values):
if not "route_ids" in values:
return super().write(values)
# As _compute_is_mto cannot use api.depends (or it would reset MTO
# route on variants as soon as there is a change on the template routes),
# we need to check which template in self had MTO route activated
# or deactivated to force the recomputation of is_mto on variants
mto_route = self.env.ref("stock.route_warehouse0_mto")
template_not_mto_before = self.filtered(lambda t: mto_route not in t.route_ids)
res = super().write(values)
templates_mto_after = self.filtered(lambda t: mto_route in t.route_ids)
templates_mto_added = template_not_mto_before & templates_mto_after
templates_mto_removed = (self - template_not_mto_before) & (self - templates_mto_after)
(templates_mto_added | templates_mto_removed).product_variant_ids._compute_is_mto()
Copy link
Contributor

Choose a reason for hiding this comment

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

return res missing

return res

@api.onchange("route_ids")
def onchange_route_ids(self):
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
if mto_route not in self._origin.route_ids and mto_route in self.route_ids._origin:
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess we can make the condition more selective and raise the warning only if we have multiple variants, and these variants have different MTO settings.

# Return warning activating MTO route
return {
"warning": {
"title": _("Warning"),
"message": _("Activating MTO route will reset `Variant is MTO` setting on the variants.")
}
}
if mto_route in self._origin.route_ids and mto_route not in self.route_ids._origin:
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above

# Return warning deactivating MTO route
return {
"warning": {
"title": _("Warning"),
"message": _("Deactivating MTO route will reset `Variant is MTO` setting on the variants.")
}
}
Loading
Loading