diff --git a/procurement_auto_create_group/models/procurement_group.py b/procurement_auto_create_group/models/procurement_group.py index b94a4cf63..ca9cb938c 100644 --- a/procurement_auto_create_group/models/procurement_group.py +++ b/procurement_auto_create_group/models/procurement_group.py @@ -13,13 +13,6 @@ class ProcurementGroup(models.Model): rule = super()._get_rule(product_id, location_id, values) # If there isn't a date planned in the values it means that this # method has been called outside of a procurement process. - if ( - rule - and not values.get("group_id") - and rule.auto_create_group - and values.get("date_planned") - ): - group_data = rule._prepare_auto_procurement_group_data() - group = self.env["procurement.group"].create(group_data) - values["group_id"] = group + if rule and rule.auto_create_group and values.get("date_planned"): + values["group_id"] = rule._get_auto_procurement_group(product_id) return rule diff --git a/procurement_auto_create_group/models/stock_rule.py b/procurement_auto_create_group/models/stock_rule.py index 0933f9c31..612387646 100644 --- a/procurement_auto_create_group/models/stock_rule.py +++ b/procurement_auto_create_group/models/stock_rule.py @@ -16,15 +16,18 @@ class StockRule(models.Model): if self.group_propagation_option != "propagate": self.auto_create_group = False + def _get_auto_procurement_group(self, product): + group_data = self._prepare_auto_procurement_group_data(product) + return self.env["procurement.group"].create(group_data) + def _push_prepare_move_copy_values(self, move_to_copy, new_date): new_move_vals = super()._push_prepare_move_copy_values(move_to_copy, new_date) if self.auto_create_group: - group_data = self._prepare_auto_procurement_group_data() - group = self.env["procurement.group"].create(group_data) + group = self._get_auto_procurement_group(move_to_copy.product_id) new_move_vals["group_id"] = group.id return new_move_vals - def _prepare_auto_procurement_group_data(self): + def _prepare_auto_procurement_group_data(self, product): name = self.env["ir.sequence"].next_by_code("procurement.group") or False if not name: raise UserError(_("No sequence defined for procurement group.")) diff --git a/procurement_auto_create_group/tests/test_auto_create.py b/procurement_auto_create_group/tests/test_auto_create.py index 69457e3c8..f1d97f879 100644 --- a/procurement_auto_create_group/tests/test_auto_create.py +++ b/procurement_auto_create_group/tests/test_auto_create.py @@ -25,7 +25,7 @@ class TestProcurementAutoCreateGroup(TransactionCase): # Create rules and routes: pull_push_route_auto = self.route_obj.create({"name": "Auto Create Group"}) - self.rule_1 = self.rule_obj.create( + self.pull_push_rule_auto = self.rule_obj.create( { "name": "rule with autocreate", "route_id": pull_push_route_auto.id, @@ -54,7 +54,7 @@ class TestProcurementAutoCreateGroup(TransactionCase): } ) push_route_auto = self.route_obj.create({"name": "Auto Create Group"}) - self.rule_1 = self.rule_obj.create( + self.push_rule_auto = self.rule_obj.create( { "name": "route_auto", "location_src_id": self.location.id, @@ -114,8 +114,12 @@ class TestProcurementAutoCreateGroup(TransactionCase): } ) + self.group = self.group_obj.create({"name": "SO0001"}) + def _procure(self, product): - values = {} + values = { + "group_id": self.group, + } self.group_obj.run( [ self.env["procurement.group"].Procurement( @@ -170,8 +174,10 @@ class TestProcurementAutoCreateGroup(TransactionCase): [("product_id", "=", self.prod_no_auto_pull_push.id)] ) self.assertTrue(move) - self.assertFalse( - move.group_id, "Procurement Group should not have been assigned." + self.assertEqual( + move.group_id, + self.group, + "Procurement Group should not have been assigned.", ) def test_02_pull_push_auto_create_group(self): @@ -189,7 +195,7 @@ class TestProcurementAutoCreateGroup(TransactionCase): def test_03_onchange_method(self): """Test onchange method for stock rule.""" - proc_rule = self.rule_1 + proc_rule = self.push_rule_auto self.assertTrue(proc_rule.auto_create_group) proc_rule.write({"group_propagation_option": "none"}) proc_rule._onchange_group_propagation_option() diff --git a/procurement_auto_create_group_by_product/README.rst b/procurement_auto_create_group_by_product/README.rst new file mode 100644 index 000000000..0e399112b --- /dev/null +++ b/procurement_auto_create_group_by_product/README.rst @@ -0,0 +1 @@ +will be generated by the boat diff --git a/procurement_auto_create_group_by_product/__init__.py b/procurement_auto_create_group_by_product/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/procurement_auto_create_group_by_product/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/procurement_auto_create_group_by_product/__manifest__.py b/procurement_auto_create_group_by_product/__manifest__.py new file mode 100644 index 000000000..f33946f26 --- /dev/null +++ b/procurement_auto_create_group_by_product/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Procurement Auto Create Group By Product", + "version": "14.0.1.0.0", + "license": "AGPL-3", + "summary": "Generate one picking per product on the procurement run.", + "author": "BCIM, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "category": "Warehouse", + "depends": ["procurement_auto_create_group"], + "data": [ + "views/stock_rule.xml", + "views/procurement_group.xml", + ], + "installable": True, +} diff --git a/procurement_auto_create_group_by_product/models/__init__.py b/procurement_auto_create_group_by_product/models/__init__.py new file mode 100644 index 000000000..c3325f776 --- /dev/null +++ b/procurement_auto_create_group_by_product/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_rule +from . import stock_move +from . import procurement_group +from . import product_product diff --git a/procurement_auto_create_group_by_product/models/procurement_group.py b/procurement_auto_create_group_by_product/models/procurement_group.py new file mode 100644 index 000000000..277667ac2 --- /dev/null +++ b/procurement_auto_create_group_by_product/models/procurement_group.py @@ -0,0 +1,10 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ProcurementGroup(models.Model): + _inherit = "procurement.group" + + product_id = fields.Many2one("product.product", index=True) diff --git a/procurement_auto_create_group_by_product/models/product_product.py b/procurement_auto_create_group_by_product/models/product_product.py new file mode 100644 index 000000000..aa8ec989b --- /dev/null +++ b/procurement_auto_create_group_by_product/models/product_product.py @@ -0,0 +1,12 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + auto_create_procurement_group_ids = fields.One2many( + "procurement.group", "product_id" + ) diff --git a/procurement_auto_create_group_by_product/models/stock_move.py b/procurement_auto_create_group_by_product/models/stock_move.py new file mode 100644 index 000000000..eeadc8bbe --- /dev/null +++ b/procurement_auto_create_group_by_product/models/stock_move.py @@ -0,0 +1,29 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from itertools import groupby + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _merge_moves(self, merge_into=False): + sorted_moves_by_rule = sorted(self, key=lambda m: m.rule_id.id) + res_moves = self.browse() + for _rule, move_list in groupby( + sorted_moves_by_rule, key=lambda m: m.rule_id.id + ): + moves = self.browse(m.id for m in move_list) + res_moves |= super(StockMove, moves)._merge_moves(merge_into=merge_into) + return res_moves + + def _prepare_merge_moves_distinct_fields(self): + result = super()._prepare_merge_moves_distinct_fields() + if self.rule_id.auto_create_group_by_product: + # Allow to merge moves on a pick operation having different + # deadlines + if "date_deadline" in result: + result.remove("date_deadline") + return result diff --git a/procurement_auto_create_group_by_product/models/stock_rule.py b/procurement_auto_create_group_by_product/models/stock_rule.py new file mode 100644 index 000000000..afbc410a4 --- /dev/null +++ b/procurement_auto_create_group_by_product/models/stock_rule.py @@ -0,0 +1,52 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import hashlib +import struct + +from odoo import fields, models + + +def pg_advisory_lock(env, int_lock: int): + """Attempts to acquire a Postgres transactional advisory lock. + + Raises an OperationalError LOCK_NOT_AVAILABLE if the lock could not be acquired. + """ + env.cr.execute( + """ + DO $$ + BEGIN + IF NOT pg_try_advisory_xact_lock(%s) THEN + RAISE EXCEPTION USING + MESSAGE = 'Lock not available', + ERRCODE = '55P03'; + END IF; + END $$; + """, + (int_lock), + ) + + +class StockRule(models.Model): + _inherit = "stock.rule" + + auto_create_group_by_product = fields.Boolean(string="Procurement Group by Product") + + def _get_auto_procurement_group(self, product): + if self.auto_create_group_by_product: + if product.auto_create_procurement_group_ids: + return fields.first(product.auto_create_procurement_group_ids) + # Make sure that two transactions cannot create a procurement group + # For the same product at the same time. + lock_name = f"product.product,{product.id}-auto-proc-group" + hasher = hashlib.sha1(str(lock_name).encode()) + bigint_lock = struct.unpack("q", hasher.digest()[:8]) + pg_advisory_lock(self.env, bigint_lock) + return super()._get_auto_procurement_group(product) + + def _prepare_auto_procurement_group_data(self, product): + result = super()._prepare_auto_procurement_group_data(product) + if self.auto_create_group_by_product: + result["product_id"] = product.id + result["partner_id"] = False + return result diff --git a/procurement_auto_create_group_by_product/readme/CONFIGURE.rst b/procurement_auto_create_group_by_product/readme/CONFIGURE.rst new file mode 100644 index 000000000..880b08f08 --- /dev/null +++ b/procurement_auto_create_group_by_product/readme/CONFIGURE.rst @@ -0,0 +1,10 @@ +#. Go to *Inventory / Configuration / Settings* and check the option + 'Multi-Step Routes' and press the 'Save' button. +#. Activate the developer mode. +#. Go to *Inventory / Configuration / Warehouse Management / Routes* + and select the route you want to change. Select the rule you wish + to change, and in case of a Pull rule or Push & Pull rule Select + 'Propagation of Procurement Group': 'Propagate'. The checkbox + 'Auto-create Procurement Group' will then appear and you can set + it if you want to procurement group to be automatically created. + Activate also the checkbox 'Procurement Group by Product'. diff --git a/procurement_auto_create_group_by_product/readme/CONTRIBUTORS.rst b/procurement_auto_create_group_by_product/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..3c6c5c696 --- /dev/null +++ b/procurement_auto_create_group_by_product/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Jacques-Etienne Baudoux (BCIM) diff --git a/procurement_auto_create_group_by_product/readme/DESCRIPTION.rst b/procurement_auto_create_group_by_product/readme/DESCRIPTION.rst new file mode 100644 index 000000000..4ac611af4 --- /dev/null +++ b/procurement_auto_create_group_by_product/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Allow to have one picking per product by using a procurement group per product +during the procurement run. diff --git a/procurement_auto_create_group_by_product/tests/__init__.py b/procurement_auto_create_group_by_product/tests/__init__.py new file mode 100644 index 000000000..a0a7f64d1 --- /dev/null +++ b/procurement_auto_create_group_by_product/tests/__init__.py @@ -0,0 +1 @@ +from . import test_auto_create_by_product diff --git a/procurement_auto_create_group_by_product/tests/test_auto_create_by_product.py b/procurement_auto_create_group_by_product/tests/test_auto_create_by_product.py new file mode 100644 index 000000000..842a02dd4 --- /dev/null +++ b/procurement_auto_create_group_by_product/tests/test_auto_create_by_product.py @@ -0,0 +1,145 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import psycopg2 + +from odoo import api, registry, tools +from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY + +from odoo.addons.procurement_auto_create_group.tests.test_auto_create import ( + TestProcurementAutoCreateGroup, +) + + +class TestProcurementAutoCreateGroupByProduct(TestProcurementAutoCreateGroup): + def test_pull_push_auto_create_group_not_by_product(self): + """Test pull flow that without option to group by product""" + self.pull_push_rule_auto.auto_create_group_by_product = False + # Behavior should be the same + super( + TestProcurementAutoCreateGroupByProduct, self + ).test_02_pull_push_auto_create_group() + + def test_pull_push_auto_create_group_by_product(self): + """Test pull flow that with option to group by product""" + self.pull_push_rule_auto.auto_create_group_by_product = True + move = self.move_obj.search([("product_id", "=", self.prod_auto_pull_push.id)]) + self.assertFalse(move) + group = self.group_obj.search( + [("product_id", "=", self.prod_auto_pull_push.id)] + ) + self.assertFalse(move) + self._procure(self.prod_auto_pull_push) + move = self.move_obj.search([("product_id", "=", self.prod_auto_pull_push.id)]) + self.assertTrue(move) + self.assertTrue(move.group_id, "Procurement Group not assigned.") + self.assertEqual( + move.group_id.product_id, + self.prod_auto_pull_push, + "Procurement Group product missing.", + ) + self.assertEqual( + move.product_uom_qty, + 5.0, + "Move invalid quantity.", + ) + self._procure(self.prod_auto_pull_push) + group = self.group_obj.search( + [("product_id", "=", self.prod_auto_pull_push.id)] + ) + self.assertEqual( + len(group), + 1, + "Procurement Group per product should be unique.", + ) + # The second move should be merged with the previous one + self.assertEqual( + move.product_uom_qty, + 10.0, + "Move invalid quantity.", + ) + + def test_push_auto_create_group_not_by_product(self): + """Test push flow that without option to group by product""" + self.push_rule_auto.auto_create_group_by_product = False + super( + TestProcurementAutoCreateGroupByProduct, self + ).test_05_push_auto_create_group() + + def test_push_auto_create_group_by_product(self): + """Test push flow that with option to group by product""" + self.push_rule_auto.auto_create_group_by_product = True + move = self.move_obj.search( + [ + ("product_id", "=", self.prod_auto_push.id), + ("location_dest_id", "=", self.loc_components.id), + ] + ) + self.assertFalse(move) + self._push_trigger(self.prod_auto_push) + move = self.move_obj.search( + [ + ("product_id", "=", self.prod_auto_push.id), + ("location_dest_id", "=", self.loc_components.id), + ] + ) + self.assertTrue(move) + self.assertTrue(move.group_id, "Procurement Group not assigned.") + self.assertEqual( + move.group_id.product_id, + self.prod_auto_push, + "Procurement Group product missing.", + ) + self._push_trigger(self.prod_auto_push) + group = self.group_obj.search([("product_id", "=", self.prod_auto_push.id)]) + self.assertEqual( + len(group), + 1, + "Procurement Group per product should be unique.", + ) + move = self.move_obj.search( + [ + ("product_id", "=", self.prod_auto_push.id), + ("location_dest_id", "=", self.loc_components.id), + ] + ) + self.assertEqual( + len(move), + 1, + "Invalid amount of moves.", + ) + self.assertEqual( + move.group_id.product_id, + self.prod_auto_push, + "Procurement Group product missing.", + ) + + def test_concurrent_procurement_group_creation(self): + """Check for the same product, no multiple procurement groups are created.""" + rule = self.pull_push_rule_auto + rule.auto_create_group_by_product = True + product = self.prod_auto_pull_push + # Check that no procurement group exist for the product + self.assertFalse(product.auto_create_procurement_group_ids) + # So create one and an adisory lock will be created + rule._get_auto_procurement_group(product) + self.assertTrue(product.auto_create_procurement_group_ids) + # Use another transaction to test the advisory lock + with registry(self.env.cr.dbname).cursor() as new_cr: + new_env = api.Environment(new_cr, self.env.uid, self.env.context) + new_env["product.product"].invalidate_cache( + ["auto_create_procurement_group_ids"], + [ + product.id, + ], + ) + rule2 = new_env["stock.rule"].browse(rule.id) + rule2.auto_create_group_by_product = True + product2 = new_env["product.product"].browse(product.id) + self.assertFalse(product2.auto_create_procurement_group_ids) + with self.assertRaises(psycopg2.OperationalError) as cm, tools.mute_logger( + "odoo.sql_db" + ): + rule2._get_auto_procurement_group(product2) + self.assertTrue(cm.exception.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY) + new_cr.rollback() diff --git a/procurement_auto_create_group_by_product/views/procurement_group.xml b/procurement_auto_create_group_by_product/views/procurement_group.xml new file mode 100644 index 000000000..07613641e --- /dev/null +++ b/procurement_auto_create_group_by_product/views/procurement_group.xml @@ -0,0 +1,13 @@ + + + + procurement_auto_create_group_by_product + procurement.group + + + + + + + + diff --git a/procurement_auto_create_group_by_product/views/stock_rule.xml b/procurement_auto_create_group_by_product/views/stock_rule.xml new file mode 100644 index 000000000..2149c0e6a --- /dev/null +++ b/procurement_auto_create_group_by_product/views/stock_rule.xml @@ -0,0 +1,33 @@ + + + + stock.rule.form - procurement_auto_create_group_by_product + stock.rule + + + + + + + + + + + diff --git a/setup/procurement_auto_create_group_by_product/odoo/addons/procurement_auto_create_group_by_product b/setup/procurement_auto_create_group_by_product/odoo/addons/procurement_auto_create_group_by_product new file mode 120000 index 000000000..50a5a3556 --- /dev/null +++ b/setup/procurement_auto_create_group_by_product/odoo/addons/procurement_auto_create_group_by_product @@ -0,0 +1 @@ +../../../../procurement_auto_create_group_by_product \ No newline at end of file diff --git a/setup/procurement_auto_create_group_by_product/setup.py b/setup/procurement_auto_create_group_by_product/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/procurement_auto_create_group_by_product/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)