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 all 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
16 changes: 16 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,16 @@
# 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
37 changes: 21 additions & 16 deletions sale_stock_mto_as_mts_orderpoint/models/stock_warehouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@ class StockWarehouse(models.Model):

_inherit = "stock.warehouse"

mto_as_mts = fields.Boolean(inverse="_inverse_mto_as_mts")
mto_as_mts = fields.Boolean()

def _get_locations_for_mto_orderpoints(self):
return self.mapped("lot_stock_id")

def _inverse_mto_as_mts(self):
mto_route = self.env.ref("stock.route_warehouse0_mto", raise_if_not_found=False)
if not mto_route:
return
def write(self, vals):
res = super().write(vals)
if "mto_as_mts" in vals and vals["mto_as_mts"] == False:
self._archive_orderpoints_on_mts_mto_removal()
return res

def _archive_orderpoints_on_mts_mto_removal(self):
for warehouse in self:
if warehouse.mto_as_mts:
wh_mto_rules = self.env["stock.rule"].search(
[
("route_id", "=", mto_route.id),
"|",
("warehouse_id", "=", warehouse.id),
("picking_type_id.warehouse_id", "=", warehouse.id),
]
)
if wh_mto_rules:
wh_mto_rules.active = False
domain = warehouse._get_orderpoints_to_archive_domain()
orderpoints = self.env["stock.warehouse.orderpoint"].search(domain)
if orderpoints:
orderpoints.write({"active": False})

def _get_orderpoints_to_archive_domain(self):
self.ensure_one()
locations = self._get_locations_for_mto_orderpoints()
return [
("product_min_qty", "=", 0.0),
("product_max_qty", "=", 0.0),
("location_id", "in", locations.ids),
]
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,39 @@
# 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 models
from odoo.osv.expression import AND


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

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

def write(self, vals):
res = super().write(vals)
if "is_mto" in vals and vals["is_mto"] == False:
self._archive_orderpoints_on_mto_removal()
return res

def _get_orderpoints_to_archive_domain(self, warehouse):
# 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?

domain = warehouse._get_orderpoints_to_archive_domain()
if self:
domain = AND(
[
domain,
[("product_id", "in", self.ids)],
]
)
return domain

def _archive_orderpoints_on_mto_removal(self):
warehouses = self.env["stock.warehouse"].search([])
for wh in warehouses:
domain = self._get_orderpoints_to_archive_domain(wh)
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,155 @@
# 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")
cls.warehouse.mto_as_mts = True

@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_warehouse_archive(self):
black_pen = self.black_pen
order = self._create_sale_order(black_pen)
order.action_confirm()
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertTrue(orderpoint)
self.warehouse.mto_as_mts = False
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertFalse(orderpoint)

def test_mto_as_mts_orderpoint_product_archive(self):
black_pen = self.black_pen
order = self._create_sale_order(black_pen)
order.action_confirm()
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertTrue(orderpoint)
self.toggle_is_mto(black_pen)
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertFalse(orderpoint)

def test_mto_as_mts_orderpoint(self):
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)
self.assertIn(self.mto_route, black_pen.route_ids)
self.assertTrue(black_pen.is_mto)
order.action_confirm()
orderpoint = self._get_orderpoint_for_products(black_pen)
self.assertTrue(orderpoint)
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):
self.assertIn(self.buy_route, self.template_pen.route_ids)
template_pen = self.template_pen
black_pen = self.black_pen
variants_pen = self.variants_pen
self.assertIn(self.buy_route, black_pen.route_ids)
# set everything to not mto
template_pen.write({"route_ids": [(3, self.mto_route.id, 0)]})
self.assertIn(self.buy_route, black_pen.route_ids)
self.assertNotIn(self.mto_route, black_pen.route_ids)
self.toggle_is_mto(variants_pen)
self.assertIn(self.mto_route, black_pen.route_ids)
self.assertIn(self.buy_route, black_pen.route_ids)
self.toggle_is_mto(variants_pen)
self.assertNotIn(self.mto_route, black_pen.route_ids)
self.assertIn(self.buy_route, black_pen.route_ids)
# 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-logistics-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
Loading
Loading