From b986e9a9207147a5e5ad2844bd300fe40d42f0ce Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Thu, 17 Sep 2020 11:25:51 +0200 Subject: [PATCH 1/8] [ADD] stock_location_product_restriction: Prevent to mix different products into the same stock location --- stock_location_product_restriction/README.rst | 100 ++++ .../__init__.py | 2 + .../__manifest__.py | 17 + stock_location_product_restriction/hooks.py | 35 ++ .../models/__init__.py | 2 + .../models/stock_location.py | 141 ++++++ .../models/stock_move.py | 103 ++++ .../readme/CONTRIBUTORS.rst | 1 + .../readme/CREDITS.rst | 4 + .../readme/DESCRIPTION.rst | 2 + .../readme/HISTORY.rst | 0 .../readme/USAGE.rst | 13 + .../readme/newsfragments/.gitkeep | 0 .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 445 ++++++++++++++++++ .../tests/__init__.py | 2 + .../tests/test_stock_location.py | 254 ++++++++++ .../tests/test_stock_move.py | 289 ++++++++++++ .../views/stock_location.xml | 51 ++ 19 files changed, 1461 insertions(+) create mode 100644 stock_location_product_restriction/README.rst create mode 100644 stock_location_product_restriction/__init__.py create mode 100644 stock_location_product_restriction/__manifest__.py create mode 100644 stock_location_product_restriction/hooks.py create mode 100644 stock_location_product_restriction/models/__init__.py create mode 100644 stock_location_product_restriction/models/stock_location.py create mode 100644 stock_location_product_restriction/models/stock_move.py create mode 100644 stock_location_product_restriction/readme/CONTRIBUTORS.rst create mode 100644 stock_location_product_restriction/readme/CREDITS.rst create mode 100644 stock_location_product_restriction/readme/DESCRIPTION.rst create mode 100644 stock_location_product_restriction/readme/HISTORY.rst create mode 100644 stock_location_product_restriction/readme/USAGE.rst create mode 100644 stock_location_product_restriction/readme/newsfragments/.gitkeep create mode 100644 stock_location_product_restriction/static/description/icon.png create mode 100644 stock_location_product_restriction/static/description/index.html create mode 100644 stock_location_product_restriction/tests/__init__.py create mode 100644 stock_location_product_restriction/tests/test_stock_location.py create mode 100644 stock_location_product_restriction/tests/test_stock_move.py create mode 100644 stock_location_product_restriction/views/stock_location.xml diff --git a/stock_location_product_restriction/README.rst b/stock_location_product_restriction/README.rst new file mode 100644 index 000000000..6dabd3e2f --- /dev/null +++ b/stock_location_product_restriction/README.rst @@ -0,0 +1,100 @@ +================================== +Stock Location Product Restriction +================================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/10.0/stock_location_product_restriction + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-10-0/stock-logistics-warehouse-10-0-stock_location_product_restriction + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/153/10.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of stock to allow you to prevent to put +items of different products into the same stock location. + + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +By default, Odoo allows you to put items of any product into the same location. +This behaviour remains the one by default once the addon is installed. +Once installed, you can specify at any level of the stock location hierarchy +if you want to restrict the usage of the location to only items of the same +product. This property is inherited by all the children locations while you +don't specify an other specific value on a child location. The constrains only +applies location by location. + +Once a location is configured to only contains items of the same product, the +system will prevent you to move items of any others products into a location +that already contains product items. A new filter into the tree view of the +stock locations will also allow you to find all the location where this new +restriction is violated. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon (https://www.acsone.eu/) + +Other credits +~~~~~~~~~~~~~ + +The development of this module has been financially supported by: + +* ACSONE SA/NV +* Alcyon Benelux + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_location_product_restriction/__init__.py b/stock_location_product_restriction/__init__.py new file mode 100644 index 000000000..6d58305f5 --- /dev/null +++ b/stock_location_product_restriction/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/stock_location_product_restriction/__manifest__.py b/stock_location_product_restriction/__manifest__.py new file mode 100644 index 000000000..4e087cf3d --- /dev/null +++ b/stock_location_product_restriction/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Location Product Restriction", + "summary": """ + Prevent to mix different products into the same stock location""", + "version": "10.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://acsone.eu/", + "depends": ["stock"], + "data": ["views/stock_location.xml"], + "demo": [], + "pre_init_hook": "pre_init_hook", +} diff --git a/stock_location_product_restriction/hooks.py b/stock_location_product_restriction/hooks.py new file mode 100644 index 000000000..7dae5f407 --- /dev/null +++ b/stock_location_product_restriction/hooks.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import logging + +_logger = logging.getLogger(__name__) + + +def column_exists(cr, tablename, columnname): + """ Return whether the given column exists. """ + query = """ SELECT 1 FROM information_schema.columns + WHERE table_name=%s AND column_name=%s """ + cr.execute(query, (tablename, columnname)) + return cr.rowcount + + +def pre_init_hook(cr): + _logger.info("Initialize product_restriction on table stock_location") + if not column_exists(cr, "stock_location", "product_restriction"): + cr.execute( + """ + ALTER TABLE stock_location + ADD COLUMN product_restriction character varying; + ALTER TABLE stock_location + ADD COLUMN parent_product_restriction character varying; + """ + ) + cr.execute( + """ + UPDATE stock_location set product_restriction = 'any'; + UPDATE stock_location set parent_product_restriction = 'any' + where location_id is not null; + """ + ) diff --git a/stock_location_product_restriction/models/__init__.py b/stock_location_product_restriction/models/__init__.py new file mode 100644 index 000000000..95b3c075e --- /dev/null +++ b/stock_location_product_restriction/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_location +from . import stock_move diff --git a/stock_location_product_restriction/models/stock_location.py b/stock_location_product_restriction/models/stock_location.py new file mode 100644 index 000000000..94f314b95 --- /dev/null +++ b/stock_location_product_restriction/models/stock_location.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models, _ +from odoo.osv.expression import NEGATIVE_TERM_OPERATORS + + +class StockLocation(models.Model): + + _inherit = "stock.location" + + product_restriction = fields.Selection( + string="Product restriction", + selection="_selection_product_restriction", + help="If 'Same product' is selected the system will prevent to put " + "items of different products into the same location.", + index=True, + required=True, + compute="_compute_product_restriction", + store=True, + default="any", + ) + + specific_product_restriction = fields.Selection( + string="Specific product restriction", + selection="_selection_product_restriction", + help="If specified the restriction specified will apply to " + "the current location and all its children", + default=False, + ) + + parent_product_restriction = fields.Selection( + selection="_selection_product_restriction", + store=True, + readonly=True, + related="location_id.product_restriction", + ) + + has_restriction_violation = fields.Boolean( + compute="_compute_restriction_violation", + search="_search_has_restriction_violation", + ) + + restriction_violation_message = fields.Char( + compute="_compute_restriction_violation" + ) + + @api.model + def _selection_product_restriction(self): + return [ + ("any", "Items of any products are allowed into the location"), + ( + "same", + "Only items of the same product allowed into the location", + ), + ] + + @api.depends("specific_product_restriction", "parent_product_restriction") + def _compute_product_restriction(self): + default_value = "any" + for rec in self: + rec.product_restriction = ( + rec.specific_product_restriction + or rec.parent_product_restriction + or default_value + ) + + @api.depends("product_restriction") + def _compute_restriction_violation(self): + records = self + if self.env.in_onchange: + records = self._origin + if not records: + # case where the compute is called from the create form + return + ProductProduct = self.env["product.product"] + SQL = """ + SELECT + stock_quant.location_id, + array_agg(distinct(product_id)) + FROM + stock_quant, + stock_location + WHERE + stock_quant.location_id in %s + and stock_location.id = stock_quant.location_id + and stock_location.product_restriction = 'same' + GROUP BY + stock_quant.location_id + HAVING count(distinct(product_id)) > 1 + """ + self.env.cr.execute(SQL, (tuple(records.ids),)) + product_ids_by_location_id = dict(self.env.cr.fetchall()) + for record in self: + record_id = record.id + if self.env.in_onchange: + record_id = self._origin.id + has_restriction_violation = False + restriction_violation_message = False + product_ids = product_ids_by_location_id.get(record_id) + if product_ids: + products = ProductProduct.browse(product_ids) + has_restriction_violation = True + restriction_violation_message = _( + "This location should only contain items of the same " + "product but it contains items of products %s" + ) % " | ".join(products.mapped("name")) + record.has_restriction_violation = has_restriction_violation + record.restriction_violation_message = ( + restriction_violation_message + ) + + def _search_has_restriction_violation(self, operator, value): + search_has_violation = ( + # has_restriction_violation != False + (operator in NEGATIVE_TERM_OPERATORS and not value) + or + # has_restriction_violation = True + (operator not in NEGATIVE_TERM_OPERATORS and value) + ) + SQL = """ + SELECT + stock_quant.location_id + FROM + stock_quant, + stock_location + WHERE + stock_location.id = stock_quant.location_id + and stock_location.product_restriction = 'same' + GROUP BY + stock_quant.location_id + HAVING count(distinct(product_id)) > 1 + """ + self.env.cr.execute(SQL) + violation_ids = [r[0] for r in self.env.cr.fetchall()] + if search_has_violation: + op = "in" + else: + op = "not in" + return [("id", op, violation_ids)] diff --git a/stock_location_product_restriction/models/stock_move.py b/stock_location_product_restriction/models/stock_move.py new file mode 100644 index 000000000..480c97b56 --- /dev/null +++ b/stock_location_product_restriction/models/stock_move.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import api, models, _ +from odoo.exceptions import ValidationError + + +class StockMove(models.Model): + + _inherit = "stock.move" + + @api.multi + def _check_location_product_restriction(self): + """ + Check if the move can be executed according to potential restriction + defined on the stock_location + """ + StockLocation = self.env["stock.location"] + ProductProduct = self.env["product.product"] + # We only check moves with a location_dest that can + # only contain the same product + moves_to_ckeck = self.filtered( + lambda m: m.location_dest_id.product_restriction == "same" + ) + if not moves_to_ckeck: + return + product_ids_location_dest_id = defaultdict(set) + error_msgs = [] + # check dest locations into the stock moves + for move in moves_to_ckeck: + product_ids_location_dest_id[move.location_dest_id.id].add( + move.product_id.id + ) + for location_id, product_ids in product_ids_location_dest_id.items(): + if len(product_ids) > 1: + location = StockLocation.browse(location_id) + products = ProductProduct.browse(list(product_ids)) + error_msgs.append( + _( + "The location %s can only contain items of the same " + "product. You plan to move different products to " + "this location. (%s)" + ) + % (location.name, ", ".join(products.mapped("name"))) + ) + + # check dest locations by taking into account product already into the + # locations + # here we use a plain SQL to avoid performance issue + SQL = """ + SELECT + location_id, + array_agg(distinct(product_id)) + FROM + stock_quant + WHERE + location_id in %s + GROUP BY + location_id + """ + self.env.cr.execute( + SQL, (tuple(moves_to_ckeck.mapped("location_dest_id").ids),) + ) + existing_product_ids_by_location_id = dict(self.env.cr.fetchall()) + for ( + location_dest_id, + existing_product_ids, + ) in existing_product_ids_by_location_id.items(): + product_ids_to_move = product_ids_location_dest_id[ + location_dest_id + ] + if set(existing_product_ids).symmetric_difference( + product_ids_to_move + ): + location = StockLocation.browse(location_dest_id) + existing_products = ProductProduct.browse(existing_product_ids) + to_move_products = ProductProduct.browse( + list(product_ids_to_move) + ) + error_msgs.append( + _( + "You plan to move the product %s to the location %s " + "but the location must only contain items of same " + "product and already contains items of other " + "product(s) " + "(%s)." + ) + % ( + " | ".join(to_move_products.mapped("name")), + location.name, + " | ".join(existing_products.mapped("name")), + ) + ) + if error_msgs: + raise ValidationError("\n".join(error_msgs)) + + @api.multi + def action_done(self): + self._check_location_product_restriction() + return super(StockMove, self).action_done() diff --git a/stock_location_product_restriction/readme/CONTRIBUTORS.rst b/stock_location_product_restriction/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..ed1929f0d --- /dev/null +++ b/stock_location_product_restriction/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon (https://www.acsone.eu/) diff --git a/stock_location_product_restriction/readme/CREDITS.rst b/stock_location_product_restriction/readme/CREDITS.rst new file mode 100644 index 000000000..c5a3e7318 --- /dev/null +++ b/stock_location_product_restriction/readme/CREDITS.rst @@ -0,0 +1,4 @@ +The development of this module has been financially supported by: + +* ACSONE SA/NV +* Alcyon Benelux diff --git a/stock_location_product_restriction/readme/DESCRIPTION.rst b/stock_location_product_restriction/readme/DESCRIPTION.rst new file mode 100644 index 000000000..4ffdded84 --- /dev/null +++ b/stock_location_product_restriction/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module extends the functionality of stock to allow you to prevent to put +items of different products into the same stock location. diff --git a/stock_location_product_restriction/readme/HISTORY.rst b/stock_location_product_restriction/readme/HISTORY.rst new file mode 100644 index 000000000..e69de29bb diff --git a/stock_location_product_restriction/readme/USAGE.rst b/stock_location_product_restriction/readme/USAGE.rst new file mode 100644 index 000000000..7917c7814 --- /dev/null +++ b/stock_location_product_restriction/readme/USAGE.rst @@ -0,0 +1,13 @@ +By default, Odoo allows you to put items of any product into the same location. +This behaviour remains the one by default once the addon is installed. +Once installed, you can specify at any level of the stock location hierarchy +if you want to restrict the usage of the location to only items of the same +product. This property is inherited by all the children locations while you +don't specify an other specific value on a child location. The constrains only +applies location by location. + +Once a location is configured to only contains items of the same product, the +system will prevent you to move items of any others products into a location +that already contains product items. A new filter into the tree view of the +stock locations will also allow you to find all the location where this new +restriction is violated. diff --git a/stock_location_product_restriction/readme/newsfragments/.gitkeep b/stock_location_product_restriction/readme/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/stock_location_product_restriction/static/description/icon.png b/stock_location_product_restriction/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/stock_location_product_restriction/static/description/index.html b/stock_location_product_restriction/static/description/index.html new file mode 100644 index 000000000..1cddee585 --- /dev/null +++ b/stock_location_product_restriction/static/description/index.html @@ -0,0 +1,445 @@ + + + + + + +Stock Location Product Restriction + + + +
+

Stock Location Product Restriction

+ + +

Beta License: AGPL-3 OCA/stock-logistics-warehouse Translate me on Weblate Try me on Runbot

+

This module extends the functionality of stock to allow you to prevent to put +items of different products into the same stock location.

+

Table of contents

+ +
+

Usage

+

By default, Odoo allows you to put items of any product into the same location. +This behaviour remains the one by default once the addon is installed. +Once installed, you can specify at any level of the stock location hierarchy +if you want to restrict the usage of the location to only items of the same +product. This property is inherited by all the children locations while you +don’t specify an other specific value on a child location. The constrains only +applies location by location.

+

Once a location is configured to only contains items of the same product, the +system will prevent you to move items of any others products into a location +that already contains product items. A new filter into the tree view of the +stock locations will also allow you to find all the location where this new +restriction is violated.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+ +
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • ACSONE SA/NV
  • +
  • Alcyon Benelux
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/stock-logistics-warehouse project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_location_product_restriction/tests/__init__.py b/stock_location_product_restriction/tests/__init__.py new file mode 100644 index 000000000..942d94611 --- /dev/null +++ b/stock_location_product_restriction/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_stock_location +from . import test_stock_move diff --git a/stock_location_product_restriction/tests/test_stock_location.py b/stock_location_product_restriction/tests/test_stock_location.py new file mode 100644 index 000000000..f9e4289be --- /dev/null +++ b/stock_location_product_restriction/tests/test_stock_location.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + + +class TestStockLocation(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestStockLocation, cls).setUpClass() + cls.StockLocation = cls.env["stock.location"] + cls.StockLocation._parent_store_compute() + cls.loc_lvl = cls.env.ref("stock.stock_location_locations") + cls.loc_lvl_1 = cls.StockLocation.create( + {"name": "level_1", "location_id": cls.loc_lvl.id} + ) + cls.loc_lvl_1_1 = cls.StockLocation.create( + {"name": "level_1_1", "location_id": cls.loc_lvl_1.id} + ) + + cls.loc_lvl_1_1_1 = cls.StockLocation.create( + {"name": "level_1_1_1", "location_id": cls.loc_lvl_1_1.id} + ) + cls.loc_lvl_1_1_2 = cls.StockLocation.create( + {"name": "level_1_1_1", "location_id": cls.loc_lvl_1_1.id} + ) + cls.default_product_restriction = "any" + + # products + Product = cls.env["product.product"] + cls.uom_unit = cls.env.ref("product.product_uom_unit") + cls.product_1 = Product.create( + {"name": "Wood", "uom_id": cls.uom_unit.id} + ) + cls.product_2 = Product.create( + {"name": "Stone", "uom_id": cls.uom_unit.id} + ) + + # quants + StockQuant = cls.env["stock.quant"] + StockQuant.create( + { + "product_id": cls.product_1.id, + "location_id": cls.loc_lvl_1_1_1.id, + "qty": 10.0, + "owner_id": cls.env.user.id, + } + ) + StockQuant.create( + { + "product_id": cls.product_2.id, + "location_id": cls.loc_lvl_1_1_1.id, + "qty": 10.0, + "owner_id": cls.env.user.id, + } + ) + StockQuant.create( + { + "product_id": cls.product_1.id, + "location_id": cls.loc_lvl_1_1_2.id, + "qty": 10.0, + "owner_id": cls.env.user.id, + } + ) + StockQuant.create( + { + "product_id": cls.product_2.id, + "location_id": cls.loc_lvl_1_1_2.id, + "qty": 10.0, + "owner_id": cls.env.user.id, + } + ) + + def test_00(self): + """ + Data: + A 3 depths location hierarchy without + specific_product_restriction + Test Case: + 1. Specify a specific_product_restriction at root level + Expected result: + The value at each level must modified. + """ + self.loc_lvl.specific_product_restriction = "same" + children = self.loc_lvl.child_ids + + def check_field(locs, name): + for loc in locs: + self.assertEqual( + name, + loc.product_restriction, + "Wrong product restriction on loc %s" % loc.name, + ) + check_field(loc.child_ids, name) + + check_field(children, "same") + + def test_01(self): + """ + Data: + A 3 depths location hierarchy without + specific_product_restriction + Test Case: + 1. Specify a specific_product_restriction at level_1_1 + Expected result: + The value at root level and level 1 is the default + The value at level_1_1 and level_1_1_1 is the new one + """ + self.loc_lvl_1_1.specific_product_restriction = "same" + self.assertEqual( + self.default_product_restriction, self.loc_lvl.product_restriction, + ) + self.assertEqual( + self.default_product_restriction, + self.loc_lvl_1.product_restriction, + ) + self.assertEqual("same", self.loc_lvl_1_1.product_restriction) + self.assertEqual("same", self.loc_lvl_1_1_1.product_restriction) + + def test_02(self): + """ + Data: + Location level_1_1_1 with 2 different products no restriction + Location level_1_1_2 with 2 different products no restriction + Test Case: + 1. Search location child of loc_lvl with restriction violation + 2. Search location child of loc_lvl without restriction violation + Expected result: + 1. No result + 2. All child location are returned + """ + self.loc_lvl_1_1_1.product_restriction = "any" + self.loc_lvl_1_1_2.product_restriction = "any" + # has violation + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", True), + ] + ) + self.assertFalse(res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", False), + ] + ) + self.assertFalse(res) + # without violation + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", False), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertIn(self.loc_lvl_1_1_2, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", True), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertIn(self.loc_lvl_1_1_2, res) + + def test_03(self): + """ + Data: + * Location level_1_1_1 with 2 different products no restriction + * Location level_1_1_2 with 2 different products + with restriction same + Test Case: + 1. Search location child of loc_lvl with restriction violation + 2. Search location child of loc_lvl without restriction violation + 3. Set restriction 'same' on location level_1_1_1 + 4. Search location child of loc_lvl with restriction violation + Expected result: + 1. result = level_1_1_2 + 2. level_1_1_2 is not into result but level_1_1_1 is + 4. result = level_1_1_2 and level_1_1_1 + """ + self.loc_lvl_1_1_1.product_restriction = "any" + self.loc_lvl_1_1_2.product_restriction = "same" + # 1 + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", True), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", False), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2, res) + # 2 + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", False), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertNotIn(self.loc_lvl_1_1_2, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", True), + ] + ) + self.assertIn(self.loc_lvl_1_1_1, res) + self.assertNotIn(self.loc_lvl_1_1_2, res) + # 3 + self.loc_lvl_1_1_1.product_restriction = "same" + self.loc_lvl_1_1_2.product_restriction = "same" + # 4 + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "=", True), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2 | self.loc_lvl_1_1_1, res) + res = self.StockLocation.search( + [ + ("id", "child_of", self.loc_lvl.id), + ("has_restriction_violation", "!=", False), + ] + ) + self.assertEqual(self.loc_lvl_1_1_2 | self.loc_lvl_1_1_1, res) + + def test_04(self): + """ + Data: + * Location level_1_1_1 with 2 different products no restriction + Test Case: + 1. Check restriction message + 3. Set restriction 'same' on location level_1_1_1 + 4. Check restriction message + Expected result: + 1. No restriction message + 3. Retriction message + """ + self.loc_lvl_1_1_1.product_restriction = "any" + self.assertFalse(self.loc_lvl_1_1_1.has_restriction_violation) + self.assertFalse(self.loc_lvl_1_1_1.restriction_violation_message) + self.loc_lvl_1_1_1.product_restriction = "same" + self.assertTrue(self.loc_lvl_1_1_1.has_restriction_violation) + self.assertTrue(self.loc_lvl_1_1_1.restriction_violation_message) diff --git a/stock_location_product_restriction/tests/test_stock_move.py b/stock_location_product_restriction/tests/test_stock_move.py new file mode 100644 index 000000000..1c86d07b4 --- /dev/null +++ b/stock_location_product_restriction/tests/test_stock_move.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from collections import namedtuple + +from odoo.tests.common import SavepointCase +from odoo.exceptions import ValidationError + +ShortMoveInfo = namedtuple( + "ShortMoveInfo", ["product", "location_dest", "qty"] +) + + +class TestStockMove(SavepointCase): + @classmethod + def setUpClass(cls): + """ + Data: + 2 products: product_1, product_2 + 1 new warehouse: warehouse_1 + 2 new locations: location_1 and location_2 are child of + warehouse_1's stock location and without + restriction + stock: + * 50 product_1 in location_1 + * 0 product_2 en stock + """ + super(TestStockMove, cls).setUpClass() + cls.uom_unit = cls.env.ref("product.product_uom_unit") + Product = cls.env["product.product"] + cls.product_1 = Product.create( + {"name": "Wood", "uom_id": cls.uom_unit.id} + ) + cls.product_2 = Product.create( + {"name": "Stone", "uom_id": cls.uom_unit.id} + ) + + # Warehouses + cls.warehouse_1 = cls.env["stock.warehouse"].create( + { + "name": "Base Warehouse", + "reception_steps": "one_step", + "delivery_steps": "ship_only", + "code": "BWH", + } + ) + + # Locations + cls.location_1 = cls.env["stock.location"].create( + { + "name": "TestLocation1", + "posx": 3, + "location_id": cls.warehouse_1.lot_stock_id.id, + } + ) + + cls.location_2 = cls.env["stock.location"].create( + { + "name": "TestLocation2", + "posx": 4, + "location_id": cls.warehouse_1.lot_stock_id.id, + } + ) + + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + + # partner + cls.partner_1 = cls.env["res.partner"].create( + {"name": "ACSONE SA/NV", "email": "info@acsone.eu"} + ) + + # picking type + cls.picking_type_in = cls.env.ref("stock.picking_type_in") + + # Inventory Add product_1 to location_1 + cls._change_product_qty(cls.product_1, cls.location_1, 50) + cls.StockMove = cls.env["stock.move"] + cls.StockPicking = cls.env["stock.picking"] + + @classmethod + def _change_product_qty(cls, product, location, qty): + inventory_wizard = cls.env["stock.change.product.qty"].create( + { + "product_id": product.id, + "new_quantity": qty, + "location_id": location.id, + } + ) + inventory_wizard.change_product_qty() + + def _get_products_in_location(self, location): + return ( + self.env["stock.quant"] + .search([("location_id", "=", location.id)]) + .mapped("product_id") + ) + + def _create_and_assign_picking(self, short_move_infos, location_dest=None): + location_dest = location_dest or self.location_1 + picking_in = self.StockPicking.create( + { + "partner_id": self.partner_1.id, + "picking_type_id": self.picking_type_in.id, + "location_id": self.supplier_location.id, + "location_dest_id": location_dest.id, + } + ) + for move_info in short_move_infos: + self.StockMove.create( + { + "name": move_info.product.name, + "product_id": move_info.product.id, + "product_uom_qty": move_info.qty, + "product_uom": move_info.product.uom_id.id, + "picking_id": picking_in.id, + "location_id": self.supplier_location.id, + "location_dest_id": move_info.location_dest.id, + } + ) + picking_in.action_confirm() + return picking_in + + def _process_picking(self, picking): + picking.force_assign() + for pack in picking.pack_operation_product_ids: + pack.qty_done = pack.product_qty + picking.do_new_transfer() + + def test_00(self): + """ + Data: + location_1 without product_restriction + location_1 with 50 product_1 + Test case: + Add qty of product_2 into location_1 + Expected result: + The location contains the 2 products + """ + self.assertEqual( + self.product_1, self._get_products_in_location(self.location_1) + ) + self._change_product_qty(self.product_2, self.location_1, 10) + self.assertEqual( + self.product_1 | self.product_2, + self._get_products_in_location(self.location_1), + ) + + def test_01(self): + """ + Data: + location_1 with same product restriction + location_1 with 50 product_1 + Test case: + Add qty of product_2 into location_1 + Expected result: + ValidationError + """ + self.assertEqual( + self.product_1, self._get_products_in_location(self.location_1) + ) + self.location_1.specific_product_restriction = "same" + with self.assertRaises(ValidationError): + self._change_product_qty(self.product_2, self.location_1, 10) + + def test_02(self): + """ + Data: + location_2 without product nor product restriction + a picking with two move with destination location_2 + Test case: + Process the picking + Expected result: + The two product are into location 2 + """ + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_2, + qty=2, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_2, + qty=2, + ), + ], + location_dest=self.location_2, + ) + self._process_picking(picking) + self.assertEqual( + self.product_1 | self.product_2, + self._get_products_in_location(self.location_2), + ) + + def test_03(self): + """ + Data: + location_2 without product but with product restriction = 'same' + a picking with two move with destination location_2 + Test case: + Process the picking + Expected result: + ValidationError + """ + self.location_2.specific_product_restriction = "same" + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_2, + qty=2, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_2, + qty=2, + ), + ], + location_dest=self.location_2, + ) + with self.assertRaises(ValidationError): + self._process_picking(picking) + + def test_04(self): + """ + Data: + location_1 with product_1 and wihout product restriction = 'same' + a picking with two moves: + * product_1 -> location_1, + * product_2 -> location_1 + Test case: + Process the picking + Expected result: + We now have two product into the same location + """ + self.assertEqual( + self.product_1, self._get_products_in_location(self.location_1), + ) + self.location_1.specific_product_restriction = "any" + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_1, + location_dest=self.location_1, + qty=2, + ), + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_1, + qty=2, + ), + ], + location_dest=self.location_1, + ) + self._process_picking(picking) + self.assertEqual( + self.product_1 | self.product_2, + self._get_products_in_location(self.location_1), + ) + + def test_05(self): + """ + Data: + location_1 with product_1 but with product restriction = 'same' + a picking with one move: product_2 -> location_1 + Test case: + Process the picking + Expected result: + ValidationError + """ + + self.assertEqual( + self.product_1, self._get_products_in_location(self.location_1), + ) + self.location_1.specific_product_restriction = "same" + picking = self._create_and_assign_picking( + [ + ShortMoveInfo( + product=self.product_2, + location_dest=self.location_1, + qty=2, + ), + ], + location_dest=self.location_1, + ) + with self.assertRaises(ValidationError): + self._process_picking(picking) diff --git a/stock_location_product_restriction/views/stock_location.xml b/stock_location_product_restriction/views/stock_location.xml new file mode 100644 index 000000000..a9432ee2e --- /dev/null +++ b/stock_location_product_restriction/views/stock_location.xml @@ -0,0 +1,51 @@ + + + + + + + stock.location.form (in stock_location_unique_product) + stock.location + + +
+ +
+ + + + + + +
+
+ + + stock.location.search (in stock_location_unique_product) + stock.location + + + + + + + + + + stock.location.tree (in stock_location_unique_product) + stock.location + + + + + + + + +
From 295fd194eda85e279c5d5179a6c0367418f8cb8f Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 9 Oct 2020 10:37:23 +0200 Subject: [PATCH 2/8] [IMP] stock_location_product_restriction: translations --- stock_location_product_restriction/i18n/fr.po | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 stock_location_product_restriction/i18n/fr.po diff --git a/stock_location_product_restriction/i18n/fr.po b/stock_location_product_restriction/i18n/fr.po new file mode 100644 index 000000000..5e6418839 --- /dev/null +++ b/stock_location_product_restriction/i18n/fr.po @@ -0,0 +1,86 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_product_restriction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-10-09 08:00+0000\n" +"PO-Revision-Date: 2020-10-09 08:00+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_has_restriction_violation +msgid "Has restriction violation" +msgstr "Ne respecte pas les règles" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_product_restriction +msgid "If 'Same product' is selected the system will prevent to put items of different products into the same location." +msgstr "Si 'Produits identiques' est sélectionné, le système empêchera de mettre des produits différents dans le même emplacement." + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "If specified the restriction specified will apply to the current location and all its children" +msgstr "Si spécifié, la règle de restriction sélectionnée s'appliquera à l'emplacement actuel et ses enfants." + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_location +msgid "Inventory Locations" +msgstr "Emplacements de stock" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_product_restriction +msgid "Product restriction" +msgstr "Restriction sur les articles" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_restriction_violation_message +msgid "Restriction violation message" +msgstr "Restriction violation message" + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_form_view +msgid "Restrictions" +msgstr "Restrictions" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "Specific product restriction" +msgstr "Spécifique restriction sur les articles" + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_move +msgid "Stock Move" +msgstr "Mouvement de stock" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:42 +#, python-format +msgid "The location %s can only contain items of the same product. You plan to move different products to this location. (%s)" +msgstr "Impossible d'effectuer le transfer. L'emplacement %s ne peut contenir que des articles identiques. (%s)" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_location.py:105 +#, python-format +msgid "This location should only contain items of the same product but it contains items of products %s" +msgstr "Cet emplacement ne peut contenir que des articles identiques mais les articles %s y sont présents." + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_search_view +msgid "With restriction violation" +msgstr "Ne respecte pas les règles" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:84 +#, python-format +msgid "You plan to move the product %s to the location %s but the location must only contain items of same product and already contains items of other product(s) (%s)." +msgstr "Impossible d'effectuer le transfer du produit %s. L'emplacement %s ne peut contenir que des articles identiques et il contient déjà: %s" From 65d58ddcf74b97083641be95d66d5923801cced2 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Sat, 24 Oct 2020 06:55:14 +0000 Subject: [PATCH 3/8] [UPD] Update stock_location_product_restriction.pot --- stock_location_product_restriction/i18n/fr.po | 44 +++++++--- .../stock_location_product_restriction.pot | 85 +++++++++++++++++++ 2 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 stock_location_product_restriction/i18n/stock_location_product_restriction.pot diff --git a/stock_location_product_restriction/i18n/fr.po b/stock_location_product_restriction/i18n/fr.po index 5e6418839..9a93caacc 100644 --- a/stock_location_product_restriction/i18n/fr.po +++ b/stock_location_product_restriction/i18n/fr.po @@ -1,6 +1,6 @@ # Translation of Odoo Server. # This file contains the translation of the following modules: -# * stock_location_product_restriction +# * stock_location_product_restriction # msgid "" msgstr "" @@ -10,6 +10,7 @@ msgstr "" "PO-Revision-Date: 2020-10-09 08:00+0000\n" "Last-Translator: <>\n" "Language-Team: \n" +"Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" @@ -23,13 +24,21 @@ msgstr "Ne respecte pas les règles" #. module: stock_location_product_restriction #: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_parent_product_restriction #: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_product_restriction -msgid "If 'Same product' is selected the system will prevent to put items of different products into the same location." -msgstr "Si 'Produits identiques' est sélectionné, le système empêchera de mettre des produits différents dans le même emplacement." +msgid "" +"If 'Same product' is selected the system will prevent to put items of " +"different products into the same location." +msgstr "" +"Si 'Produits identiques' est sélectionné, le système empêchera de mettre des " +"produits différents dans le même emplacement." #. module: stock_location_product_restriction #: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_specific_product_restriction -msgid "If specified the restriction specified will apply to the current location and all its children" -msgstr "Si spécifié, la règle de restriction sélectionnée s'appliquera à l'emplacement actuel et ses enfants." +msgid "" +"If specified the restriction specified will apply to the current location " +"and all its children" +msgstr "" +"Si spécifié, la règle de restriction sélectionnée s'appliquera à " +"l'emplacement actuel et ses enfants." #. module: stock_location_product_restriction #: model:ir.model,name:stock_location_product_restriction.model_stock_location @@ -65,14 +74,22 @@ msgstr "Mouvement de stock" #. module: stock_location_product_restriction #: code:addons/stock_location_product_restriction/models/stock_move.py:42 #, python-format -msgid "The location %s can only contain items of the same product. You plan to move different products to this location. (%s)" -msgstr "Impossible d'effectuer le transfer. L'emplacement %s ne peut contenir que des articles identiques. (%s)" +msgid "" +"The location %s can only contain items of the same product. You plan to move " +"different products to this location. (%s)" +msgstr "" +"Impossible d'effectuer le transfer. L'emplacement %s ne peut contenir que " +"des articles identiques. (%s)" #. module: stock_location_product_restriction #: code:addons/stock_location_product_restriction/models/stock_location.py:105 #, python-format -msgid "This location should only contain items of the same product but it contains items of products %s" -msgstr "Cet emplacement ne peut contenir que des articles identiques mais les articles %s y sont présents." +msgid "" +"This location should only contain items of the same product but it contains " +"items of products %s" +msgstr "" +"Cet emplacement ne peut contenir que des articles identiques mais les " +"articles %s y sont présents." #. module: stock_location_product_restriction #: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_search_view @@ -82,5 +99,10 @@ msgstr "Ne respecte pas les règles" #. module: stock_location_product_restriction #: code:addons/stock_location_product_restriction/models/stock_move.py:84 #, python-format -msgid "You plan to move the product %s to the location %s but the location must only contain items of same product and already contains items of other product(s) (%s)." -msgstr "Impossible d'effectuer le transfer du produit %s. L'emplacement %s ne peut contenir que des articles identiques et il contient déjà: %s" +msgid "" +"You plan to move the product %s to the location %s but the location must " +"only contain items of same product and already contains items of other " +"product(s) (%s)." +msgstr "" +"Impossible d'effectuer le transfer du produit %s. L'emplacement %s ne peut " +"contenir que des articles identiques et il contient déjà: %s" diff --git a/stock_location_product_restriction/i18n/stock_location_product_restriction.pot b/stock_location_product_restriction/i18n/stock_location_product_restriction.pot new file mode 100644 index 000000000..07b1989fe --- /dev/null +++ b/stock_location_product_restriction/i18n/stock_location_product_restriction.pot @@ -0,0 +1,85 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_product_restriction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_has_restriction_violation +msgid "Has restriction violation" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_product_restriction +msgid "If 'Same product' is selected the system will prevent to put items of different products into the same location." +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,help:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "If specified the restriction specified will apply to the current location and all its children" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_parent_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_product_restriction +msgid "Product restriction" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_restriction_violation_message +msgid "Restriction violation message" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_form_view +msgid "Restrictions" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model.fields,field_description:stock_location_product_restriction.field_stock_location_specific_product_restriction +msgid "Specific product restriction" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.model,name:stock_location_product_restriction.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:42 +#, python-format +msgid "The location %s can only contain items of the same product. You plan to move different products to this location. (%s)" +msgstr "" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_location.py:105 +#, python-format +msgid "This location should only contain items of the same product but it contains items of products %s" +msgstr "" + +#. module: stock_location_product_restriction +#: model:ir.ui.view,arch_db:stock_location_product_restriction.stock_location_search_view +msgid "With restriction violation" +msgstr "" + +#. module: stock_location_product_restriction +#: code:addons/stock_location_product_restriction/models/stock_move.py:84 +#, python-format +msgid "You plan to move the product %s to the location %s but the location must only contain items of same product and already contains items of other product(s) (%s)." +msgstr "" + From cac9838b63117dd32458dc3b2125d36ded7c8e18 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 24 Oct 2020 07:35:49 +0000 Subject: [PATCH 4/8] [UPD] README.rst --- stock_location_product_restriction/README.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/stock_location_product_restriction/README.rst b/stock_location_product_restriction/README.rst index 6dabd3e2f..067fe5268 100644 --- a/stock_location_product_restriction/README.rst +++ b/stock_location_product_restriction/README.rst @@ -28,7 +28,6 @@ Stock Location Product Restriction This module extends the functionality of stock to allow you to prevent to put items of different products into the same stock location. - **Table of contents** .. contents:: From 5d7c466977764a3b28dc1e500a0b5b861e9bfc34 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 24 Oct 2020 07:35:49 +0000 Subject: [PATCH 5/8] stock_location_product_restriction 10.0.1.0.1 --- stock_location_product_restriction/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_location_product_restriction/__manifest__.py b/stock_location_product_restriction/__manifest__.py index 4e087cf3d..4e8201afe 100644 --- a/stock_location_product_restriction/__manifest__.py +++ b/stock_location_product_restriction/__manifest__.py @@ -6,7 +6,7 @@ "name": "Stock Location Product Restriction", "summary": """ Prevent to mix different products into the same stock location""", - "version": "10.0.1.0.0", + "version": "10.0.1.0.1", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", "website": "https://acsone.eu/", From 29038b77a431443b3e6e2b03b1ba7e603746de4c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 5 Sep 2022 16:10:17 +0000 Subject: [PATCH 6/8] stock_location_product_restriction 10.0.1.0.2 --- stock_location_product_restriction/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_location_product_restriction/__manifest__.py b/stock_location_product_restriction/__manifest__.py index 4e8201afe..0a7b1127a 100644 --- a/stock_location_product_restriction/__manifest__.py +++ b/stock_location_product_restriction/__manifest__.py @@ -6,7 +6,7 @@ "name": "Stock Location Product Restriction", "summary": """ Prevent to mix different products into the same stock location""", - "version": "10.0.1.0.1", + "version": "10.0.1.0.2", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", "website": "https://acsone.eu/", From 7b67e18e19148536cfef8243ed0782880a20075a Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 19 Oct 2022 12:11:03 +0200 Subject: [PATCH 7/8] [IMP] stock_location_product_restriction: pre-commit stuff --- .../odoo/__init__.py | 1 + .../odoo/addons/__init__.py | 1 + .../addons/stock_location_product_restriction | 1 + .../setup.py | 6 +++ .../__manifest__.py | 3 +- stock_location_product_restriction/hooks.py | 3 +- .../models/stock_location.py | 7 +--- .../models/stock_move.py | 15 ++----- .../tests/test_stock_location.py | 12 ++---- .../tests/test_stock_move.py | 21 ++++------ .../views/stock_location.xml | 39 ++++++++++++------- 11 files changed, 53 insertions(+), 56 deletions(-) create mode 100644 setup/stock_location_product_restriction/odoo/__init__.py create mode 100644 setup/stock_location_product_restriction/odoo/addons/__init__.py create mode 120000 setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction create mode 100644 setup/stock_location_product_restriction/setup.py diff --git a/setup/stock_location_product_restriction/odoo/__init__.py b/setup/stock_location_product_restriction/odoo/__init__.py new file mode 100644 index 000000000..de40ea7ca --- /dev/null +++ b/setup/stock_location_product_restriction/odoo/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/setup/stock_location_product_restriction/odoo/addons/__init__.py b/setup/stock_location_product_restriction/odoo/addons/__init__.py new file mode 100644 index 000000000..de40ea7ca --- /dev/null +++ b/setup/stock_location_product_restriction/odoo/addons/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction b/setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction new file mode 120000 index 000000000..7717f33c9 --- /dev/null +++ b/setup/stock_location_product_restriction/odoo/addons/stock_location_product_restriction @@ -0,0 +1 @@ +../../../../stock_location_product_restriction \ No newline at end of file diff --git a/setup/stock_location_product_restriction/setup.py b/setup/stock_location_product_restriction/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_location_product_restriction/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_location_product_restriction/__manifest__.py b/stock_location_product_restriction/__manifest__.py index 0a7b1127a..3a5701147 100644 --- a/stock_location_product_restriction/__manifest__.py +++ b/stock_location_product_restriction/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). @@ -9,7 +8,7 @@ "version": "10.0.1.0.2", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", - "website": "https://acsone.eu/", + "website": "https://github.com/OCA/stock-logistics-warehouse", "depends": ["stock"], "data": ["views/stock_location.xml"], "demo": [], diff --git a/stock_location_product_restriction/hooks.py b/stock_location_product_restriction/hooks.py index 7dae5f407..a1b7d82f5 100644 --- a/stock_location_product_restriction/hooks.py +++ b/stock_location_product_restriction/hooks.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) @@ -8,7 +7,7 @@ _logger = logging.getLogger(__name__) def column_exists(cr, tablename, columnname): - """ Return whether the given column exists. """ + """Return whether the given column exists.""" query = """ SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name=%s """ cr.execute(query, (tablename, columnname)) diff --git a/stock_location_product_restriction/models/stock_location.py b/stock_location_product_restriction/models/stock_location.py index 94f314b95..42f45ce1a 100644 --- a/stock_location_product_restriction/models/stock_location.py +++ b/stock_location_product_restriction/models/stock_location.py @@ -1,8 +1,7 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models, _ +from odoo import _, api, fields, models from odoo.osv.expression import NEGATIVE_TERM_OPERATORS @@ -107,9 +106,7 @@ class StockLocation(models.Model): "product but it contains items of products %s" ) % " | ".join(products.mapped("name")) record.has_restriction_violation = has_restriction_violation - record.restriction_violation_message = ( - restriction_violation_message - ) + record.restriction_violation_message = restriction_violation_message def _search_has_restriction_violation(self, operator, value): search_has_violation = ( diff --git a/stock_location_product_restriction/models/stock_move.py b/stock_location_product_restriction/models/stock_move.py index 480c97b56..807b07334 100644 --- a/stock_location_product_restriction/models/stock_move.py +++ b/stock_location_product_restriction/models/stock_move.py @@ -1,10 +1,9 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from collections import defaultdict -from odoo import api, models, _ +from odoo import _, api, models from odoo.exceptions import ValidationError @@ -69,17 +68,11 @@ class StockMove(models.Model): location_dest_id, existing_product_ids, ) in existing_product_ids_by_location_id.items(): - product_ids_to_move = product_ids_location_dest_id[ - location_dest_id - ] - if set(existing_product_ids).symmetric_difference( - product_ids_to_move - ): + product_ids_to_move = product_ids_location_dest_id[location_dest_id] + if set(existing_product_ids).symmetric_difference(product_ids_to_move): location = StockLocation.browse(location_dest_id) existing_products = ProductProduct.browse(existing_product_ids) - to_move_products = ProductProduct.browse( - list(product_ids_to_move) - ) + to_move_products = ProductProduct.browse(list(product_ids_to_move)) error_msgs.append( _( "You plan to move the product %s to the location %s " diff --git a/stock_location_product_restriction/tests/test_stock_location.py b/stock_location_product_restriction/tests/test_stock_location.py index f9e4289be..cc3716d55 100644 --- a/stock_location_product_restriction/tests/test_stock_location.py +++ b/stock_location_product_restriction/tests/test_stock_location.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). @@ -30,12 +29,8 @@ class TestStockLocation(SavepointCase): # products Product = cls.env["product.product"] cls.uom_unit = cls.env.ref("product.product_uom_unit") - cls.product_1 = Product.create( - {"name": "Wood", "uom_id": cls.uom_unit.id} - ) - cls.product_2 = Product.create( - {"name": "Stone", "uom_id": cls.uom_unit.id} - ) + cls.product_1 = Product.create({"name": "Wood", "uom_id": cls.uom_unit.id}) + cls.product_2 = Product.create({"name": "Stone", "uom_id": cls.uom_unit.id}) # quants StockQuant = cls.env["stock.quant"] @@ -109,7 +104,8 @@ class TestStockLocation(SavepointCase): """ self.loc_lvl_1_1.specific_product_restriction = "same" self.assertEqual( - self.default_product_restriction, self.loc_lvl.product_restriction, + self.default_product_restriction, + self.loc_lvl.product_restriction, ) self.assertEqual( self.default_product_restriction, diff --git a/stock_location_product_restriction/tests/test_stock_move.py b/stock_location_product_restriction/tests/test_stock_move.py index 1c86d07b4..b4f42dfc1 100644 --- a/stock_location_product_restriction/tests/test_stock_move.py +++ b/stock_location_product_restriction/tests/test_stock_move.py @@ -1,15 +1,12 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from collections import namedtuple -from odoo.tests.common import SavepointCase from odoo.exceptions import ValidationError +from odoo.tests.common import SavepointCase -ShortMoveInfo = namedtuple( - "ShortMoveInfo", ["product", "location_dest", "qty"] -) +ShortMoveInfo = namedtuple("ShortMoveInfo", ["product", "location_dest", "qty"]) class TestStockMove(SavepointCase): @@ -29,12 +26,8 @@ class TestStockMove(SavepointCase): super(TestStockMove, cls).setUpClass() cls.uom_unit = cls.env.ref("product.product_uom_unit") Product = cls.env["product.product"] - cls.product_1 = Product.create( - {"name": "Wood", "uom_id": cls.uom_unit.id} - ) - cls.product_2 = Product.create( - {"name": "Stone", "uom_id": cls.uom_unit.id} - ) + cls.product_1 = Product.create({"name": "Wood", "uom_id": cls.uom_unit.id}) + cls.product_2 = Product.create({"name": "Stone", "uom_id": cls.uom_unit.id}) # Warehouses cls.warehouse_1 = cls.env["stock.warehouse"].create( @@ -236,7 +229,8 @@ class TestStockMove(SavepointCase): We now have two product into the same location """ self.assertEqual( - self.product_1, self._get_products_in_location(self.location_1), + self.product_1, + self._get_products_in_location(self.location_1), ) self.location_1.specific_product_restriction = "any" picking = self._create_and_assign_picking( @@ -272,7 +266,8 @@ class TestStockMove(SavepointCase): """ self.assertEqual( - self.product_1, self._get_products_in_location(self.location_1), + self.product_1, + self._get_products_in_location(self.location_1), ) self.location_1.specific_product_restriction = "same" picking = self._create_and_assign_picking( diff --git a/stock_location_product_restriction/views/stock_location.xml b/stock_location_product_restriction/views/stock_location.xml index a9432ee2e..823aa589c 100644 --- a/stock_location_product_restriction/views/stock_location.xml +++ b/stock_location_product_restriction/views/stock_location.xml @@ -1,49 +1,58 @@ - + - - stock.location.form (in stock_location_unique_product) + stock.location.form (in stock_location_unique_product) stock.location - +
- - + +
- stock.location.search (in stock_location_unique_product) + stock.location.search (in stock_location_unique_product) stock.location - + - + - stock.location.tree (in stock_location_unique_product) + stock.location.tree (in stock_location_unique_product) stock.location - + - + From 9c7e5cef8b2a981907d8b55b76e5ee356d6e9c06 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 20 Oct 2022 10:32:28 +0200 Subject: [PATCH 8/8] [16.0][MIG] stock_location_product_restriction --- .../odoo/__init__.py | 1 - .../odoo/addons/__init__.py | 1 - .../__manifest__.py | 4 +- .../models/__init__.py | 3 +- .../models/stock_location.py | 30 ++++++--------- .../models/stock_move.py | 31 ++++++++-------- .../readme/CONTRIBUTORS.rst | 1 + .../tests/test_stock_location.py | 36 +++++++++++------- .../tests/test_stock_move.py | 37 +++++++++++-------- .../views/stock_location.xml | 6 +-- 10 files changed, 79 insertions(+), 71 deletions(-) delete mode 100644 setup/stock_location_product_restriction/odoo/__init__.py delete mode 100644 setup/stock_location_product_restriction/odoo/addons/__init__.py diff --git a/setup/stock_location_product_restriction/odoo/__init__.py b/setup/stock_location_product_restriction/odoo/__init__.py deleted file mode 100644 index de40ea7ca..000000000 --- a/setup/stock_location_product_restriction/odoo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__import__('pkg_resources').declare_namespace(__name__) diff --git a/setup/stock_location_product_restriction/odoo/addons/__init__.py b/setup/stock_location_product_restriction/odoo/addons/__init__.py deleted file mode 100644 index de40ea7ca..000000000 --- a/setup/stock_location_product_restriction/odoo/addons/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__import__('pkg_resources').declare_namespace(__name__) diff --git a/stock_location_product_restriction/__manifest__.py b/stock_location_product_restriction/__manifest__.py index 3a5701147..1ae8a1a6d 100644 --- a/stock_location_product_restriction/__manifest__.py +++ b/stock_location_product_restriction/__manifest__.py @@ -5,12 +5,12 @@ "name": "Stock Location Product Restriction", "summary": """ Prevent to mix different products into the same stock location""", - "version": "10.0.1.0.2", + "version": "16.0.1.0.0", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "maintainers": ["lmignon", "rousseldenis"], "website": "https://github.com/OCA/stock-logistics-warehouse", "depends": ["stock"], "data": ["views/stock_location.xml"], - "demo": [], "pre_init_hook": "pre_init_hook", } diff --git a/stock_location_product_restriction/models/__init__.py b/stock_location_product_restriction/models/__init__.py index 95b3c075e..4d109959e 100644 --- a/stock_location_product_restriction/models/__init__.py +++ b/stock_location_product_restriction/models/__init__.py @@ -1,2 +1 @@ -from . import stock_location -from . import stock_move +from . import stock_location, stock_move diff --git a/stock_location_product_restriction/models/stock_location.py b/stock_location_product_restriction/models/stock_location.py index 42f45ce1a..89371a1db 100644 --- a/stock_location_product_restriction/models/stock_location.py +++ b/stock_location_product_restriction/models/stock_location.py @@ -1,5 +1,5 @@ # Copyright 2020 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import _, api, fields, models from odoo.osv.expression import NEGATIVE_TERM_OPERATORS @@ -10,8 +10,7 @@ class StockLocation(models.Model): _inherit = "stock.location" product_restriction = fields.Selection( - string="Product restriction", - selection="_selection_product_restriction", + selection=lambda self: self._selection_product_restriction(), help="If 'Same product' is selected the system will prevent to put " "items of different products into the same location.", index=True, @@ -19,30 +18,33 @@ class StockLocation(models.Model): compute="_compute_product_restriction", store=True, default="any", + recursive=True, ) specific_product_restriction = fields.Selection( - string="Specific product restriction", - selection="_selection_product_restriction", + selection=lambda self: self._selection_product_restriction(), help="If specified the restriction specified will apply to " "the current location and all its children", default=False, ) parent_product_restriction = fields.Selection( - selection="_selection_product_restriction", + string="Parent Location Product Restriction", store=True, readonly=True, related="location_id.product_restriction", + recursive=True, ) has_restriction_violation = fields.Boolean( compute="_compute_restriction_violation", search="_search_has_restriction_violation", + recursive=True, ) restriction_violation_message = fields.Char( - compute="_compute_restriction_violation" + compute="_compute_restriction_violation", + recursive=True, ) @api.model @@ -68,11 +70,6 @@ class StockLocation(models.Model): @api.depends("product_restriction") def _compute_restriction_violation(self): records = self - if self.env.in_onchange: - records = self._origin - if not records: - # case where the compute is called from the create form - return ProductProduct = self.env["product.product"] SQL = """ SELECT @@ -93,8 +90,6 @@ class StockLocation(models.Model): product_ids_by_location_id = dict(self.env.cr.fetchall()) for record in self: record_id = record.id - if self.env.in_onchange: - record_id = self._origin.id has_restriction_violation = False restriction_violation_message = False product_ids = product_ids_by_location_id.get(record_id) @@ -103,8 +98,8 @@ class StockLocation(models.Model): has_restriction_violation = True restriction_violation_message = _( "This location should only contain items of the same " - "product but it contains items of products %s" - ) % " | ".join(products.mapped("name")) + "product but it contains items of products {products}" + ).format(products=" | ".join(products.mapped("name"))) record.has_restriction_violation = has_restriction_violation record.restriction_violation_message = restriction_violation_message @@ -112,9 +107,8 @@ class StockLocation(models.Model): search_has_violation = ( # has_restriction_violation != False (operator in NEGATIVE_TERM_OPERATORS and not value) - or # has_restriction_violation = True - (operator not in NEGATIVE_TERM_OPERATORS and value) + or (operator not in NEGATIVE_TERM_OPERATORS and value) ) SQL = """ SELECT diff --git a/stock_location_product_restriction/models/stock_move.py b/stock_location_product_restriction/models/stock_move.py index 807b07334..233571c2e 100644 --- a/stock_location_product_restriction/models/stock_move.py +++ b/stock_location_product_restriction/models/stock_move.py @@ -1,9 +1,9 @@ # Copyright 2020 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from collections import defaultdict -from odoo import _, api, models +from odoo import _, models from odoo.exceptions import ValidationError @@ -11,7 +11,6 @@ class StockMove(models.Model): _inherit = "stock.move" - @api.multi def _check_location_product_restriction(self): """ Check if the move can be executed according to potential restriction @@ -39,11 +38,13 @@ class StockMove(models.Model): products = ProductProduct.browse(list(product_ids)) error_msgs.append( _( - "The location %s can only contain items of the same " + "The location {location} can only contain items of the same " "product. You plan to move different products to " - "this location. (%s)" + "this location. ({products})" + ).format( + location=location.name, + products=", ".join(products.mapped("name")), ) - % (location.name, ", ".join(products.mapped("name"))) ) # check dest locations by taking into account product already into the @@ -75,22 +76,20 @@ class StockMove(models.Model): to_move_products = ProductProduct.browse(list(product_ids_to_move)) error_msgs.append( _( - "You plan to move the product %s to the location %s " + "You plan to move the product {product} to the location {location} " "but the location must only contain items of same " "product and already contains items of other " "product(s) " - "(%s)." - ) - % ( - " | ".join(to_move_products.mapped("name")), - location.name, - " | ".join(existing_products.mapped("name")), + "({existing_products})." + ).format( + product=" | ".join(to_move_products.mapped("name")), + location=location.name, + existing_products=" | ".join(existing_products.mapped("name")), ) ) if error_msgs: raise ValidationError("\n".join(error_msgs)) - @api.multi - def action_done(self): + def _action_done(self, cancel_backorder=False): self._check_location_product_restriction() - return super(StockMove, self).action_done() + return super()._action_done(cancel_backorder=cancel_backorder) diff --git a/stock_location_product_restriction/readme/CONTRIBUTORS.rst b/stock_location_product_restriction/readme/CONTRIBUTORS.rst index ed1929f0d..8fa151ccf 100644 --- a/stock_location_product_restriction/readme/CONTRIBUTORS.rst +++ b/stock_location_product_restriction/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ * Laurent Mignon (https://www.acsone.eu/) +* Denis Roussel (https://www.acsone.eu/) diff --git a/stock_location_product_restriction/tests/test_stock_location.py b/stock_location_product_restriction/tests/test_stock_location.py index cc3716d55..a57eddcde 100644 --- a/stock_location_product_restriction/tests/test_stock_location.py +++ b/stock_location_product_restriction/tests/test_stock_location.py @@ -1,13 +1,14 @@ # Copyright 2020 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo.tests.common import SavepointCase +from odoo.tests.common import TransactionCase -class TestStockLocation(SavepointCase): +class TestStockLocation(TransactionCase): @classmethod def setUpClass(cls): - super(TestStockLocation, cls).setUpClass() + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.StockLocation = cls.env["stock.location"] cls.StockLocation._parent_store_compute() cls.loc_lvl = cls.env.ref("stock.stock_location_locations") @@ -28,9 +29,13 @@ class TestStockLocation(SavepointCase): # products Product = cls.env["product.product"] - cls.uom_unit = cls.env.ref("product.product_uom_unit") - cls.product_1 = Product.create({"name": "Wood", "uom_id": cls.uom_unit.id}) - cls.product_2 = Product.create({"name": "Stone", "uom_id": cls.uom_unit.id}) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.product_1 = Product.create( + {"name": "Wood", "type": "product", "uom_id": cls.uom_unit.id} + ) + cls.product_2 = Product.create( + {"name": "Stone", "type": "product", "uom_id": cls.uom_unit.id} + ) # quants StockQuant = cls.env["stock.quant"] @@ -38,7 +43,7 @@ class TestStockLocation(SavepointCase): { "product_id": cls.product_1.id, "location_id": cls.loc_lvl_1_1_1.id, - "qty": 10.0, + "quantity": 10.0, "owner_id": cls.env.user.id, } ) @@ -46,7 +51,7 @@ class TestStockLocation(SavepointCase): { "product_id": cls.product_2.id, "location_id": cls.loc_lvl_1_1_1.id, - "qty": 10.0, + "quantity": 10.0, "owner_id": cls.env.user.id, } ) @@ -54,7 +59,7 @@ class TestStockLocation(SavepointCase): { "product_id": cls.product_1.id, "location_id": cls.loc_lvl_1_1_2.id, - "qty": 10.0, + "quantity": 10.0, "owner_id": cls.env.user.id, } ) @@ -62,7 +67,7 @@ class TestStockLocation(SavepointCase): { "product_id": cls.product_2.id, "location_id": cls.loc_lvl_1_1_2.id, - "qty": 10.0, + "quantity": 10.0, "owner_id": cls.env.user.id, } ) @@ -77,8 +82,8 @@ class TestStockLocation(SavepointCase): Expected result: The value at each level must modified. """ - self.loc_lvl.specific_product_restriction = "same" - children = self.loc_lvl.child_ids + self.loc_lvl_1.specific_product_restriction = "same" + children = self.loc_lvl_1.child_ids def check_field(locs, name): for loc in locs: @@ -103,6 +108,7 @@ class TestStockLocation(SavepointCase): The value at level_1_1 and level_1_1_1 is the new one """ self.loc_lvl_1_1.specific_product_restriction = "same" + self.loc_lvl_1_1.flush_recordset() self.assertEqual( self.default_product_restriction, self.loc_lvl.product_restriction, @@ -179,6 +185,7 @@ class TestStockLocation(SavepointCase): """ self.loc_lvl_1_1_1.product_restriction = "any" self.loc_lvl_1_1_2.product_restriction = "same" + (self.loc_lvl_1_1_1 | self.loc_lvl_1_1_2).flush_recordset() # 1 res = self.StockLocation.search( [ @@ -214,6 +221,7 @@ class TestStockLocation(SavepointCase): # 3 self.loc_lvl_1_1_1.product_restriction = "same" self.loc_lvl_1_1_2.product_restriction = "same" + (self.loc_lvl_1_1_1 | self.loc_lvl_1_1_2).flush_recordset() # 4 res = self.StockLocation.search( [ @@ -243,8 +251,10 @@ class TestStockLocation(SavepointCase): 3. Retriction message """ self.loc_lvl_1_1_1.product_restriction = "any" + self.loc_lvl_1_1_1.flush_recordset() self.assertFalse(self.loc_lvl_1_1_1.has_restriction_violation) self.assertFalse(self.loc_lvl_1_1_1.restriction_violation_message) self.loc_lvl_1_1_1.product_restriction = "same" + self.loc_lvl_1_1_1.flush_recordset() self.assertTrue(self.loc_lvl_1_1_1.has_restriction_violation) self.assertTrue(self.loc_lvl_1_1_1.restriction_violation_message) diff --git a/stock_location_product_restriction/tests/test_stock_move.py b/stock_location_product_restriction/tests/test_stock_move.py index b4f42dfc1..2573c5da0 100644 --- a/stock_location_product_restriction/tests/test_stock_move.py +++ b/stock_location_product_restriction/tests/test_stock_move.py @@ -1,15 +1,15 @@ # Copyright 2020 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from collections import namedtuple from odoo.exceptions import ValidationError -from odoo.tests.common import SavepointCase +from odoo.tests.common import TransactionCase ShortMoveInfo = namedtuple("ShortMoveInfo", ["product", "location_dest", "qty"]) -class TestStockMove(SavepointCase): +class TestStockMove(TransactionCase): @classmethod def setUpClass(cls): """ @@ -23,11 +23,16 @@ class TestStockMove(SavepointCase): * 50 product_1 in location_1 * 0 product_2 en stock """ - super(TestStockMove, cls).setUpClass() - cls.uom_unit = cls.env.ref("product.product_uom_unit") + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") Product = cls.env["product.product"] - cls.product_1 = Product.create({"name": "Wood", "uom_id": cls.uom_unit.id}) - cls.product_2 = Product.create({"name": "Stone", "uom_id": cls.uom_unit.id}) + cls.product_1 = Product.create( + {"name": "Wood", "type": "product", "uom_id": cls.uom_unit.id} + ) + cls.product_2 = Product.create( + {"name": "Stone", "type": "product", "uom_id": cls.uom_unit.id} + ) # Warehouses cls.warehouse_1 = cls.env["stock.warehouse"].create( @@ -73,14 +78,13 @@ class TestStockMove(SavepointCase): @classmethod def _change_product_qty(cls, product, location, qty): - inventory_wizard = cls.env["stock.change.product.qty"].create( + cls.env["stock.quant"].with_context(inventory_mode=True).create( { "product_id": product.id, - "new_quantity": qty, + "inventory_quantity": qty, "location_id": location.id, } - ) - inventory_wizard.change_product_qty() + )._apply_inventory() def _get_products_in_location(self, location): return ( @@ -115,10 +119,10 @@ class TestStockMove(SavepointCase): return picking_in def _process_picking(self, picking): - picking.force_assign() - for pack in picking.pack_operation_product_ids: - pack.qty_done = pack.product_qty - picking.do_new_transfer() + picking.action_assign() + for line in picking.move_line_ids: + line.qty_done = line.reserved_qty + picking.button_validate() def test_00(self): """ @@ -153,6 +157,7 @@ class TestStockMove(SavepointCase): self.product_1, self._get_products_in_location(self.location_1) ) self.location_1.specific_product_restriction = "same" + self.location_1.flush_recordset() with self.assertRaises(ValidationError): self._change_product_qty(self.product_2, self.location_1, 10) @@ -198,6 +203,7 @@ class TestStockMove(SavepointCase): ValidationError """ self.location_2.specific_product_restriction = "same" + self.location_2.flush_recordset() picking = self._create_and_assign_picking( [ ShortMoveInfo( @@ -270,6 +276,7 @@ class TestStockMove(SavepointCase): self._get_products_in_location(self.location_1), ) self.location_1.specific_product_restriction = "same" + self.location_1.invalidate_recordset() picking = self._create_and_assign_picking( [ ShortMoveInfo( diff --git a/stock_location_product_restriction/views/stock_location.xml b/stock_location_product_restriction/views/stock_location.xml index 823aa589c..b698319b1 100644 --- a/stock_location_product_restriction/views/stock_location.xml +++ b/stock_location_product_restriction/views/stock_location.xml @@ -1,12 +1,12 @@ + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). --> stock.location.form (in stock_location_unique_product) + >stock.location.form (in stock_location_product_restriction) stock.location @@ -30,7 +30,7 @@ stock.location.search (in stock_location_unique_product) + >stock.location.search (in stock_location_product_restriction) stock.location