diff --git a/stock_location_orderpoint/tests/common.py b/stock_location_orderpoint/tests/common.py index c1e1f6c6c..add44c625 100644 --- a/stock_location_orderpoint/tests/common.py +++ b/stock_location_orderpoint/tests/common.py @@ -20,8 +20,9 @@ class TestLocationOrderpointCommon(SavepointCase): cls.location_dest = cls.warehouse.lot_stock_id cls.env["stock.location.orderpoint"].search([]).write({"active": False}) - def _create_picking_type(self, name, location_src, location_dest, warehouse): - return self.env["stock.picking.type"].create( + @classmethod + def _create_picking_type(cls, name, location_src, location_dest, warehouse): + return cls.env["stock.picking.type"].create( { "name": name, "sequence_code": f"INT/REPL/{location_src.name}", @@ -33,8 +34,9 @@ class TestLocationOrderpointCommon(SavepointCase): } ) - def _create_route(self, name, picking_type, location_src, location_dest, warehouse): - return self.env["stock.location.route"].create( + @classmethod + def _create_route(cls, name, picking_type, location_src, location_dest, warehouse): + return cls.env["stock.location.route"].create( { "name": name, "sequence": 0, @@ -56,69 +58,79 @@ class TestLocationOrderpointCommon(SavepointCase): } ) - def _create_picking_type_route_rule(self, location): + @classmethod + def _create_picking_type_route_rule(cls, location): name = "Internal Replenishment" name = f"{name}-{location.name}" - picking_type = self._create_picking_type( - name, location, self.location_dest, self.warehouse + picking_type = cls._create_picking_type( + name, location, cls.location_dest, cls.warehouse ) - route = self._create_route( - name, picking_type, location, self.location_dest, self.warehouse + route = cls._create_route( + name, picking_type, location, cls.location_dest, cls.warehouse ) return picking_type, route - def _create_orderpoint(self, **kwargs): - location_orderpoint = Form(self.env["stock.location.orderpoint"]) - location_orderpoint.location_id = self.location_dest + @classmethod + def _create_orderpoint(cls, **kwargs): + location_orderpoint = Form(cls.env["stock.location.orderpoint"]) + location_orderpoint.location_id = cls.location_dest for field, value in kwargs.items(): setattr(location_orderpoint, field, value) return location_orderpoint.save() - def _create_move(self, name, qty, location, location_dest): - move = self.env["stock.move"].create( + @classmethod + def _create_move(cls, name, qty, location, location_dest, defaults=None): + vals = defaults or {} + vals.update( { "name": name, "date": datetime.today(), - "product_id": self.product.id, - "product_uom": self.uom_unit.id, + "product_id": cls.product.id, + "product_uom": cls.uom_unit.id, "product_uom_qty": qty, "location_id": location.id, "location_dest_id": location_dest.id, } ) + move = cls.env["stock.move"].create(vals) move._write({"create_date": datetime.now()}) move._action_confirm() return move - def _create_scrap_move(self, qty, location): - scrap = self.env["stock.location"].search( + @classmethod + def _create_scrap_move(cls, qty, location): + scrap = cls.env["stock.location"].search( [("scrap_location", "=", True)], limit=1 ) - move = self._create_move("Scrap", qty, location, scrap) + move = cls._create_move("Scrap", qty, location, scrap) move.move_line_ids.write({"qty_done": qty}) move._action_done() return move - def _create_incoming_move(self, qty, location): - move = self._create_move( - "Receive", qty, self.env.ref("stock.stock_location_suppliers"), location + @classmethod + def _create_incoming_move(cls, qty, location): + move = cls._create_move( + "Receive", qty, cls.env.ref("stock.stock_location_suppliers"), location ) move.move_line_ids.write({"qty_done": qty}) move._action_done() return move - def _create_outgoing_move(self, qty, location=None): - move = self._create_move( + @classmethod + def _create_outgoing_move(cls, qty, location=None, defaults=None): + move = cls._create_move( "Delivery", qty, - location or self.location_dest, - self.env.ref("stock.stock_location_customers"), + location or cls.location_dest, + cls.env.ref("stock.stock_location_customers"), + defaults=defaults, ) move._action_assign() return move - def _create_quants(self, product, location, qty): - self.env["stock.quant"].create( + @classmethod + def _create_quants(cls, product, location, qty): + cls.env["stock.quant"].create( { "product_id": product.id, "location_id": location.id, @@ -148,15 +160,17 @@ class TestLocationOrderpointCommon(SavepointCase): self.assertEqual(move.state, "assigned") self.assertEqual(move.priority, orderpoint.priority) - def _create_location(self, name): - return self.env["stock.location"].create( - {"name": name, "location_id": self.location_dest.location_id.id} + @classmethod + def _create_location(cls, name): + return cls.env["stock.location"].create( + {"name": name, "location_id": cls.location_dest.location_id.id} ) - def _create_orderpoint_complete(self, location_name, **kwargs): - location = self._create_location(location_name) - picking_type, route = self._create_picking_type_route_rule(location) + @classmethod + def _create_orderpoint_complete(cls, location_name, **kwargs): + location = cls._create_location(location_name) + picking_type, route = cls._create_picking_type_route_rule(location) values = kwargs or {} values.update({"route_id": route}) - orderpoint = self._create_orderpoint(**values) + orderpoint = cls._create_orderpoint(**values) return orderpoint, location diff --git a/stock_location_orderpoint_source_relocate/__manifest__.py b/stock_location_orderpoint_source_relocate/__manifest__.py index 879c9e6b0..2014f45aa 100644 --- a/stock_location_orderpoint_source_relocate/__manifest__.py +++ b/stock_location_orderpoint_source_relocate/__manifest__.py @@ -1,11 +1,12 @@ # Copyright 2023 Michael Tietz (MT Software) +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). { "name": "stock_location_orderpoint_source_relocate", - "author": "MT Software, Odoo Community Association (OCA)", + "author": "MT Software, BCIM, Odoo Community Association (OCA)", "summary": "Run an auto location orderpoint replenishment " - "also after a move gets relocated by Stock Move Source Relocate", + "after the move relocation done by Stock Move Source Relocate", "version": "14.0.1.0.2", "development_status": "Alpha", "data": [], @@ -14,6 +15,6 @@ "stock_move_source_relocate", ], "license": "AGPL-3", - "maintainers": ["mt-software-de"], + "maintainers": ["mt-software-de", "jbaudoux"], "website": "https://github.com/OCA/stock-logistics-warehouse", } diff --git a/stock_location_orderpoint_source_relocate/models/__init__.py b/stock_location_orderpoint_source_relocate/models/__init__.py index 6bda2d242..c29f9cbbf 100644 --- a/stock_location_orderpoint_source_relocate/models/__init__.py +++ b/stock_location_orderpoint_source_relocate/models/__init__.py @@ -1 +1,2 @@ +from . import stock_location_orderpoint from . import stock_move diff --git a/stock_location_orderpoint_source_relocate/models/stock_location_orderpoint.py b/stock_location_orderpoint_source_relocate/models/stock_location_orderpoint.py new file mode 100644 index 000000000..1b1913a8b --- /dev/null +++ b/stock_location_orderpoint_source_relocate/models/stock_location_orderpoint.py @@ -0,0 +1,43 @@ +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, models +from odoo.tools import float_round + + +class StockLocationOrderpoint(models.Model): + _inherit = "stock.location.orderpoint" + + @api.model + def _compute_quantities_dict(self, locations, products): + qties = super()._compute_quantities_dict(locations, products) + # With the source relocation, we could have stock on the location that + # is reserved by moves with a source location on the parent location. + # Those moves are not considered by the standard virtual available + # stock. + Move = self.env["stock.move"].with_context(active_test=False) + for location, location_dict in qties.items(): + products = products.with_context(location=location.id) + _, _, domain_move_out_loc = products._get_domain_locations() + domain_move_out_loc_todo = [ + ( + "state", + "in", + ("waiting", "confirmed", "assigned", "partially_available"), + ) + ] + domain_move_out_loc + for product, qty in location_dict.items(): + moves = Move.search( + domain_move_out_loc_todo + [("product_id", "=", product.id)], + order="id", + ) + rounding = product.uom_id.rounding + unreserved_availability = float_round( + qty["outgoing_qty"] - sum(m.reserved_availability for m in moves), + precision_rounding=rounding, + ) + qty["virtual_available"] = float_round( + qty["free_qty"] + qty["incoming_qty"] - unreserved_availability, + precision_rounding=rounding, + ) + + return qties diff --git a/stock_location_orderpoint_source_relocate/models/stock_move.py b/stock_location_orderpoint_source_relocate/models/stock_move.py index e11238873..158877e98 100644 --- a/stock_location_orderpoint_source_relocate/models/stock_move.py +++ b/stock_location_orderpoint_source_relocate/models/stock_move.py @@ -1,4 +1,5 @@ # Copyright 2023 Michael Tietz (MT Software) +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import models @@ -6,17 +7,12 @@ from odoo import models class StockMove(models.Model): _inherit = "stock.move" - def _action_assign(self, *args, **kwargs): + def _action_assign(self): self = self.with_context(skip_auto_replenishment=True) - res = super()._action_assign(*args, **kwargs) - self = self.with_context(skip_auto_replenishment=False) - return res + super()._action_assign() - def _apply_source_relocate_rule(self, *args, **kwargs): - relocated = super()._apply_source_relocate_rule(*args, **kwargs) - if not relocated: - return relocated - relocated.with_context( - skip_auto_replenishment=False - )._prepare_auto_replenishment_for_waiting_moves() - return relocated + def _apply_source_relocate(self): + res = super()._apply_source_relocate() + res = res.with_context(skip_auto_replenishment=False) + res._prepare_auto_replenishment_for_waiting_moves() + return res diff --git a/stock_location_orderpoint_source_relocate/readme/CONTRIBUTORS.rst b/stock_location_orderpoint_source_relocate/readme/CONTRIBUTORS.rst index 446fd1d57..03c3145a2 100644 --- a/stock_location_orderpoint_source_relocate/readme/CONTRIBUTORS.rst +++ b/stock_location_orderpoint_source_relocate/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ * Michael Tietz (MT Software) +* Jacques-Etienne Baudoux (BCIM) diff --git a/stock_location_orderpoint_source_relocate/readme/DESCRIPTION.rst b/stock_location_orderpoint_source_relocate/readme/DESCRIPTION.rst index 3edebb253..a6e878825 100644 --- a/stock_location_orderpoint_source_relocate/readme/DESCRIPTION.rst +++ b/stock_location_orderpoint_source_relocate/readme/DESCRIPTION.rst @@ -1 +1 @@ -Run an auto location orderpoint replenishment also after a move gets relocated by Stock Move Source Relocate +Run the auto location orderpoint replenishment after the potiential move relocation by Stock Move Source Relocate diff --git a/stock_location_orderpoint_source_relocate/tests/test_location_orderpoint.py b/stock_location_orderpoint_source_relocate/tests/test_location_orderpoint.py index 29a8860ec..f58b1efe5 100644 --- a/stock_location_orderpoint_source_relocate/tests/test_location_orderpoint.py +++ b/stock_location_orderpoint_source_relocate/tests/test_location_orderpoint.py @@ -11,48 +11,52 @@ from odoo.addons.stock_move_source_relocate.tests.common import SourceRelocateCo class TestLocationOrderpoint(TestLocationOrderpointCommon, SourceRelocateCommon): - def test_auto_replenishment(self): + @classmethod + def setUpClass(cls): + super().setUpClass() name = "Internal Replenishment" - replenishment_location = self.env["stock.location"].create( + cls.replenishment_location = cls.env["stock.location"].create( { "name": name, - "location_id": self.wh.lot_stock_id.location_id.id, + "location_id": cls.wh.lot_stock_id.location_id.id, } ) - internal_location = replenishment_location.create( + cls.internal_location = cls.env["stock.location"].create( { "name": name, - "location_id": self.wh.lot_stock_id.id, + "location_id": cls.wh.lot_stock_id.id, } ) - picking_type = self._create_picking_type( - name, replenishment_location, internal_location, self.wh + picking_type = cls._create_picking_type( + name, cls.replenishment_location, cls.internal_location, cls.wh ) - route = self._create_route( - name, picking_type, replenishment_location, internal_location, self.wh + route = cls._create_route( + name, + picking_type, + cls.replenishment_location, + cls.internal_location, + cls.wh, ) + cls.orderpoint = Form(cls.env["stock.location.orderpoint"]) + cls.orderpoint.location_id = cls.internal_location + cls.orderpoint.route_id = route + cls.orderpoint = cls.orderpoint.save() - orderpoint = Form(self.env["stock.location.orderpoint"]) - orderpoint.location_id = internal_location - orderpoint.route_id = route - orderpoint = orderpoint.save() + cls._create_incoming_move(10, cls.replenishment_location) + cls.job_func = cls.env["stock.location.orderpoint"].run_auto_replenishment - job_func = self.env["stock.location.orderpoint"].run_auto_replenishment - - self._create_incoming_move(10, replenishment_location) - self._create_relocate_rule( - self.wh.lot_stock_id, internal_location, self.wh.out_type_id - ) + def test_auto_replenishment_without_relocation(self): with trap_jobs() as trap: - move = self._create_outgoing_move(10, self.wh.lot_stock_id) - move.picking_type_id = self.wh.out_type_id.id - move._assign_picking() - move._action_assign() - self.assertEqual(move.location_id, internal_location) - trap.assert_jobs_count(1, only=job_func) + move = self._create_outgoing_move( + 10, + self.internal_location, + defaults={"picking_type_id": self.wh.out_type_id.id}, + ) + self.assertEqual(move.location_id, self.internal_location) + trap.assert_jobs_count(1, only=self.job_func) trap.assert_enqueued_job( - job_func, - args=(move.product_id, internal_location, "location_id"), + self.job_func, + args=(move.product_id, self.internal_location, "location_id"), kwargs={}, properties=dict( identity_key=identity_exact, @@ -61,5 +65,58 @@ class TestLocationOrderpoint(TestLocationOrderpointCommon, SourceRelocateCommon) self.product.invalidate_cache() trap.perform_enqueued_jobs() - replenish_move = self._get_replenishment_move(orderpoint) - self._check_replenishment_move(replenish_move, 10, orderpoint) + replenish_move = self._get_replenishment_move(self.orderpoint) + self._check_replenishment_move(replenish_move, 10, self.orderpoint) + + def test_auto_replenishment_with_relocation(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.internal_location, self.wh.out_type_id + ) + with trap_jobs() as trap: + move = self._create_outgoing_move( + 10, + self.wh.lot_stock_id, + defaults={"picking_type_id": self.wh.out_type_id.id}, + ) + self.assertEqual(move.location_id, self.internal_location) + trap.assert_jobs_count(1, only=self.job_func) + trap.assert_enqueued_job( + self.job_func, + args=(move.product_id, self.internal_location, "location_id"), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + + self.product.invalidate_cache() + trap.perform_enqueued_jobs() + replenish_move = self._get_replenishment_move(self.orderpoint) + self._check_replenishment_move(replenish_move, 10, self.orderpoint) + + def test_auto_replenishment_with_partial_relocation(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.internal_location, self.wh.out_type_id + ) + self._create_quants(self.product, self.internal_location, 1) + with trap_jobs() as trap: + move = self._create_outgoing_move( + 10, + self.wh.lot_stock_id, + defaults={"picking_type_id": self.wh.out_type_id.id}, + ) + self.assertEqual(move.location_id, self.wh.lot_stock_id) + trap.assert_jobs_count(1, only=self.job_func) + trap.assert_enqueued_job( + self.job_func, + args=(move.product_id, self.internal_location, "location_id"), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + + self.product.invalidate_cache() + trap.perform_enqueued_jobs() + replenish_move = self._get_replenishment_move(self.orderpoint) + self._check_replenishment_move(replenish_move, 9, self.orderpoint)