diff --git a/setup/stock_move_source_relocate/odoo/addons/stock_move_source_relocate b/setup/stock_move_source_relocate/odoo/addons/stock_move_source_relocate new file mode 120000 index 0000000000..edd64cbd4f --- /dev/null +++ b/setup/stock_move_source_relocate/odoo/addons/stock_move_source_relocate @@ -0,0 +1 @@ +../../../../stock_move_source_relocate \ No newline at end of file diff --git a/setup/stock_move_source_relocate/setup.py b/setup/stock_move_source_relocate/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_move_source_relocate/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/stock_move_source_relocate_dynamic_routing/odoo/addons/stock_move_source_relocate_dynamic_routing b/setup/stock_move_source_relocate_dynamic_routing/odoo/addons/stock_move_source_relocate_dynamic_routing new file mode 120000 index 0000000000..67882d9f30 --- /dev/null +++ b/setup/stock_move_source_relocate_dynamic_routing/odoo/addons/stock_move_source_relocate_dynamic_routing @@ -0,0 +1 @@ +../../../../stock_move_source_relocate_dynamic_routing \ No newline at end of file diff --git a/setup/stock_move_source_relocate_dynamic_routing/setup.py b/setup/stock_move_source_relocate_dynamic_routing/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_move_source_relocate_dynamic_routing/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_move_source_relocate/__init__.py b/stock_move_source_relocate/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_move_source_relocate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_move_source_relocate/__manifest__.py b/stock_move_source_relocate/__manifest__.py new file mode 100644 index 0000000000..965430b019 --- /dev/null +++ b/stock_move_source_relocate/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "Stock Move Source Relocation", + "summary": "Change source location of unavailable moves", + "version": "13.0.1.0.0", + "development_status": "Alpha", + "category": "Warehouse Management", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": ["stock"], + "data": ["views/stock_source_relocate_views.xml", "security/ir.model.access.csv"], +} diff --git a/stock_move_source_relocate/models/__init__.py b/stock_move_source_relocate/models/__init__.py new file mode 100644 index 0000000000..d5a42034f2 --- /dev/null +++ b/stock_move_source_relocate/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_move +from . import stock_location +from . import stock_source_relocate diff --git a/stock_move_source_relocate/models/stock_location.py b/stock_move_source_relocate/models/stock_location.py new file mode 100644 index 0000000000..60caf1b3f8 --- /dev/null +++ b/stock_move_source_relocate/models/stock_location.py @@ -0,0 +1,14 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + def is_sublocation_of(self, others): + """Return True if self is a sublocation of at least one other""" + self.ensure_one() + # Efficient way to verify that the current location is + # below one of the other location without using SQL. + return any(self.parent_path.startswith(other.parent_path) for other in others) diff --git a/stock_move_source_relocate/models/stock_move.py b/stock_move_source_relocate/models/stock_move.py new file mode 100644 index 0000000000..aaa965ddb5 --- /dev/null +++ b/stock_move_source_relocate/models/stock_move.py @@ -0,0 +1,69 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import models +from odoo.tools.float_utils import float_is_zero + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _action_assign(self): + unconfirmed_moves = self.filtered( + lambda m: m.state in ["confirmed", "partially_available"] + ) + super()._action_assign() + # could not be (entirely) reserved + unconfirmed_moves = unconfirmed_moves.filtered( + lambda m: m.state in ["confirmed", "partially_available"] + ) + unconfirmed_moves._apply_source_relocate() + + def _apply_source_relocate(self): + # Read the `reserved_availability` field of the moves out of the loop + # to prevent unwanted cache invalidation when actually reserving. + reserved_availability = {move: move.reserved_availability for move in self} + roundings = {move: move.product_id.uom_id.rounding for move in self} + for move in self: + # We don't need to ignore moves with "_should_bypass_reservation() + # is True" because they are reserved at this point. + relocation = self.env["stock.source.relocate"]._rule_for_move(move) + if not relocation or relocation.relocate_location_id == move.location_id: + continue + move._apply_source_relocate_rule( + relocation, reserved_availability, roundings + ) + + def _apply_source_relocate_rule(self, relocation, reserved_availability, roundings): + relocated = self.env["stock.move"].browse() + + rounding = roundings[self] + if not reserved_availability[self]: + # nothing could be reserved, however, we want to source the + # move on the specific relocation (for replenishment), so + # update it's source location + self.location_id = relocation.relocate_location_id + relocated = self + else: + missing_reserved_uom_quantity = ( + self.product_uom_qty - reserved_availability[self] + ) + need = self.product_uom._compute_quantity( + missing_reserved_uom_quantity, + self.product_id.uom_id, + rounding_method="HALF-UP", + ) + + if float_is_zero(need, precision_rounding=rounding): + return relocated + + # A part of the quantity could be reserved in the original + # location, so keep this part in the move and split the rest + # in a new move, where will take the goods in the relocation + new_move_id = self._split(need) + # recheck first move which should now be available + new_move = self.browse(new_move_id) + new_move.location_id = relocation.relocate_location_id + self._action_assign() + relocated = new_move + return relocated diff --git a/stock_move_source_relocate/models/stock_source_relocate.py b/stock_move_source_relocate/models/stock_source_relocate.py new file mode 100644 index 0000000000..726bc2b208 --- /dev/null +++ b/stock_move_source_relocate/models/stock_source_relocate.py @@ -0,0 +1,149 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +def _default_sequence(record): + maxrule = record.search([], order="sequence desc", limit=1) + if maxrule: + return maxrule.sequence + 10 + else: + return 0 + + +class StockSourceRelocate(models.Model): + """Rules for move source relocating + + Each rule can have many removal rules, they configure the conditions and + advanced removal strategies to apply on a specific location (sub-location + of the rule). + + The rules are selected for a move based on their source location and a + configurable domain on the rule. + """ + + _name = "stock.source.relocate" + _description = "Stock Move Source Relocate" + _order = "sequence, id" + + sequence = fields.Integer(default=lambda s: _default_sequence(s)) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + comodel_name="res.company", default=lambda self: self.env.user.company_id.id + ) + + location_id = fields.Many2one(comodel_name="stock.location", required=True) + relocate_location_id = fields.Many2one(comodel_name="stock.location", required=True) + picking_type_id = fields.Many2one(comodel_name="stock.picking.type", required=True) + + rule_domain = fields.Char( + string="Rule Domain", + default=[], + help="Domain based on Stock Moves, to define if the " + "rule is applicable or not.", + ) + rule_message = fields.Html(compute="_compute_rule_message") + + @api.constrains("relocate_location_id") + def _constraint_relocate_location_id(self): + """The relocate location has to be a child of the main location.""" + for rule in self: + if not rule.relocate_location_id.is_sublocation_of(rule.location_id): + msg = _("Relocate location has to be a sub-location of '{}'.").format( + rule.location_id.display_name + ) + raise ValidationError(msg) + + def _rule_message_template(self): + message = _( + "When a move with operation type " + "{rule.picking_type_id.display_name}" + " is inside the location" + " {rule.location_id.display_name} and a check of" + " availability returns no reservation, the move is relocated" + " to the location" + " {rule.relocate_location_id.display_name}" + " (source location changed).
" + "If a move is partially unavailable, the move is split in two" + " parts:" + ) + # we need to eval the domain to see if it's not "[]" + if safe_eval(self.rule_domain) or []: + message += _( + "
" + "This rule is applied only if the domain" + " matches with the move." + ) + return message + + @api.depends( + "location_id", "relocate_location_id", "picking_type_id", "rule_domain" + ) + def _compute_rule_message(self): + """Generate dynamically describing the rule for humans""" + for rule in self: + if not ( + rule.picking_type_id and rule.location_id and rule.relocate_location_id + ): + rule.rule_message = "" + continue + rule.rule_message = rule._rule_message_template().format(rule=rule) + + def name_get(self): + res = [] + for record in self: + res.append( + ( + record.id, + "{} → {}".format( + self.location_id.display_name, + self.relocate_location_id.display_name, + ), + ) + ) + return res + + def _rule_for_move(self, move): + rules = self.search( + [ + ("picking_type_id", "=", move.picking_type_id.id), + ("location_id", "parent_of", move.location_id.id), + ] + ) + for rule in rules: + if rule._is_rule_applicable(move): + return rule + return self.browse() + + def _eval_rule_domain(self, move, domain): + move_domain = [("id", "=", move.id)] + # Warning: if we build a domain with dotted path such + # as group_id.is_urgent (hypothetic field), can become very + # slow as odoo searches all "procurement.group.is_urgent" first + # then uses "IN group_ids" on the stock move only. + # In such situations, it can be better either to add a related + # field on the stock.move, either extend _eval_rule_domain to + # add your own logic (based on SQL, ...). + return bool( + self.env["stock.move"].search( + expression.AND([move_domain, domain]), limit=1 + ) + ) + + def _is_rule_applicable(self, move): + domain = safe_eval(self.rule_domain) or [] + if domain: + return self._eval_rule_domain(move, domain) + return True diff --git a/stock_move_source_relocate/readme/CONFIGURE.rst b/stock_move_source_relocate/readme/CONFIGURE.rst new file mode 100644 index 0000000000..e39bd83988 --- /dev/null +++ b/stock_move_source_relocate/readme/CONFIGURE.rst @@ -0,0 +1,13 @@ +The configuration of the source relocations is done in "Inventory > Configuration > Source Relocation". + +Creation of a rule: + +Properties that define where the rule will be applied: + +* Location: any unreserved move in this location or sub-location is relocated +* Picking Type: any unreserved move in this picking type is relocated +* Rule Domain: filter the moves to relocate with arbitrary domains + +Note: all of the above must be met to relocate a move. + +The Relocate Location field defines what the move source location will be changed to. It must be a sub-location of the location. diff --git a/stock_move_source_relocate/readme/CONTRIBUTORS.rst b/stock_move_source_relocate/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..48286263cd --- /dev/null +++ b/stock_move_source_relocate/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_move_source_relocate/readme/DESCRIPTION.rst b/stock_move_source_relocate/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..b22a11f0a6 --- /dev/null +++ b/stock_move_source_relocate/readme/DESCRIPTION.rst @@ -0,0 +1,31 @@ +Relocate source location of unconfirmed moves + +Add relocation rules for moves. + +Some use cases: + +* Handle all the replenishments at the same place +* Trigger minimum stock rules or DDMRP buffers in one location + +Behavior: + +* When we try to assign a stock move and the move is not available, a rule + matching the source location (sub-locations included), the picking type and an + optional domain is searched +* If a relocation is found, the move source location is updated with the new one +* If the move was partially available, it is split in 2 parts: + + * one available part which keeps its source location + * one confirmed part which is updated with the new source location + +Notes: + +Goes well with ``stock_available_to_promise_release``. +When using the mentioned module, we assume that we release moves (which +creates the whole chain of moves) only when we know that we have the +quantities in stock (otherwise the module splits the delivery). So generally, +we have the goods are available, but maybe not at the correct place: this +module is handy to organize internal replenishments. + +Compatible with ``stock_dynamic_routing``: when the source location is updated +by this module, a dynamic routing may be applied. diff --git a/stock_move_source_relocate/security/ir.model.access.csv b/stock_move_source_relocate/security/ir.model.access.csv new file mode 100644 index 0000000000..9a65977248 --- /dev/null +++ b/stock_move_source_relocate/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_source_relocate_stock_user,access_stock_source_relocate stock user,model_stock_source_relocate,stock.group_stock_user,1,0,0,0 +access_stock_source_relocate_manager,access_stock_source_relocate stock manager,model_stock_source_relocate,stock.group_stock_manager,1,1,1,1 diff --git a/stock_move_source_relocate/tests/__init__.py b/stock_move_source_relocate/tests/__init__.py new file mode 100644 index 0000000000..cd19e16be0 --- /dev/null +++ b/stock_move_source_relocate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_source_relocate diff --git a/stock_move_source_relocate/tests/test_source_relocate.py b/stock_move_source_relocate/tests/test_source_relocate.py new file mode 100644 index 0000000000..5696605611 --- /dev/null +++ b/stock_move_source_relocate/tests/test_source_relocate.py @@ -0,0 +1,267 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) + +from odoo import exceptions +from odoo.tests import common + + +class SourceRelocateCommon(common.SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_delta = cls.env.ref("base.res_partner_4") + cls.wh = cls.env["stock.warehouse"].create( + { + "name": "Base Warehouse", + "reception_steps": "one_step", + "delivery_steps": "pick_ship", + "code": "WHTEST", + } + ) + cls.customer_loc = cls.env.ref("stock.stock_location_customers") + + cls.loc_shelf = cls.env["stock.location"].create( + {"name": "Shelves", "location_id": cls.wh.lot_stock_id.id} + ) + cls.loc_shelf_1 = cls.env["stock.location"].create( + {"name": "Shelf 1", "location_id": cls.loc_shelf.id} + ) + cls.loc_shelf_2 = cls.env["stock.location"].create( + {"name": "Shelf 2", "location_id": cls.loc_shelf.id} + ) + cls.loc_replenish = cls.env["stock.location"].create( + {"name": "Replenish", "location_id": cls.loc_shelf.id} + ) + + cls.product = cls.env["product.product"].create( + {"name": "Product", "type": "product"} + ) + cls.product2 = cls.env["product.product"].create( + {"name": "Product2", "type": "product"} + ) + + def _create_single_move(self, product, picking_type): + move_vals = { + "name": product.name, + "picking_type_id": picking_type.id, + "product_id": product.id, + "product_uom_qty": 10.0, + "product_uom": product.uom_id.id, + "location_id": picking_type.default_location_src_id.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "state": "confirmed", + "procure_method": "make_to_stock", + } + return self.env["stock.move"].create(move_vals) + + def _create_relocate_rule(self, location, relocation, picking_type, domain=None): + self.env["stock.source.relocate"].create( + { + "location_id": location.id, + "picking_type_id": picking_type.id, + "relocate_location_id": relocation.id, + "rule_domain": domain or "[]", + } + ) + + def _update_qty_in_location(self, location, product, quantity): + self.env["stock.quant"]._update_available_quantity(product, location, quantity) + + +class TestSourceRelocate(SourceRelocateCommon): + def test_relocate_child_of_location(self): + # relocate location is a child, valid + self.env["stock.source.relocate"].create( + { + "location_id": self.loc_shelf.id, + "picking_type_id": self.wh.pick_type_id.id, + "relocate_location_id": self.loc_replenish.id, + } + ) + + def test_relocate_not_child_of_location(self): + # relocate location must be a child + with self.assertRaises(exceptions.ValidationError): + self.env["stock.source.relocate"].create( + { + "location_id": self.loc_shelf.id, + "picking_type_id": self.wh.pick_type_id.id, + "relocate_location_id": self.customer_loc.id, + } + ) + + def test_relocate_whole_move(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.loc_replenish, self.wh.pick_type_id + ) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move._assign_picking() + move._action_assign() + self.assertRecordValues( + move, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + "location_id": self.loc_replenish.id, + } + ], + ) + + def test_relocate_partial_move(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.loc_replenish, self.wh.pick_type_id + ) + self._update_qty_in_location(self.loc_shelf_1, self.product, 3) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move._assign_picking() + move._action_assign() + + self.assertRecordValues( + move, + [ + { + "state": "assigned", + "product_qty": 3.0, + "reserved_availability": 3.0, + "location_id": self.wh.lot_stock_id.id, + } + ], + ) + new_move = move.picking_id.move_lines - move + self.assertRecordValues( + new_move, + [ + { + "state": "confirmed", + "product_qty": 7.0, + "reserved_availability": 0.0, + "location_id": self.loc_replenish.id, + } + ], + ) + + def test_relocate_ignore_available(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.loc_replenish, self.wh.pick_type_id + ) + self._update_qty_in_location(self.loc_shelf_1, self.product, 10) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move._assign_picking() + move._action_assign() + self.assertRecordValues( + move, + [ + { + "state": "assigned", + "product_qty": 10.0, + "reserved_availability": 10.0, + # keep the original location when it's available + "location_id": self.wh.lot_stock_id.id, + } + ], + ) + + def test_relocate_domain(self): + self._create_relocate_rule( + self.wh.lot_stock_id, + self.loc_replenish, + self.wh.pick_type_id, + domain=[("product_id", "=", self.product.id)], + ) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move2 = self._create_single_move(self.product2, self.wh.pick_type_id) + moves = move + move2 + moves._assign_picking() + moves._action_assign() + self.assertRecordValues( + move, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + "location_id": self.loc_replenish.id, + } + ], + ) + self.assertRecordValues( + move2, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + # the domain exclude this move from the relocation + "location_id": self.wh.lot_stock_id.id, + } + ], + ) + + def test_relocate_rule_picking_type(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.loc_replenish, self.wh.pick_type_id + ) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move2 = self._create_single_move(self.product2, self.wh.int_type_id) + move2.location_id = self.wh.lot_stock_id + moves = move + move2 + moves._assign_picking() + moves._action_assign() + self.assertRecordValues( + move, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + "location_id": self.loc_replenish.id, + } + ], + ) + self.assertRecordValues( + move2, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + # excluded by different picking type + "location_id": self.wh.lot_stock_id.id, + } + ], + ) + + def test_relocate_rule_location(self): + self._create_relocate_rule( + self.wh.lot_stock_id, self.loc_replenish, self.wh.pick_type_id + ) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move2 = self._create_single_move(self.product2, self.wh.pick_type_id) + move2.location_id = self.wh.wh_input_stock_loc_id + moves = move + move2 + moves._assign_picking() + moves._action_assign() + self.assertRecordValues( + move, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + "location_id": self.loc_replenish.id, + } + ], + ) + self.assertRecordValues( + move2, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + # excluded by different location + "location_id": self.wh.wh_input_stock_loc_id.id, + } + ], + ) diff --git a/stock_move_source_relocate/views/stock_source_relocate_views.xml b/stock_move_source_relocate/views/stock_source_relocate_views.xml new file mode 100644 index 0000000000..cc56a65c2f --- /dev/null +++ b/stock_move_source_relocate/views/stock_source_relocate_views.xml @@ -0,0 +1,96 @@ + + + + stock.source.relocate.form + stock.source.relocate + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + stock.source.relocate.search + stock.source.relocate + + + + + + + + + + + + stock.source.relocate + stock.source.relocate + + + + + + + + + + + + Source Relocation + stock.source.relocate + ir.actions.act_window + + + + +

+ Add a Source Relocation +

+
+
+ +
diff --git a/stock_move_source_relocate_dynamic_routing/__init__.py b/stock_move_source_relocate_dynamic_routing/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_move_source_relocate_dynamic_routing/__manifest__.py b/stock_move_source_relocate_dynamic_routing/__manifest__.py new file mode 100644 index 0000000000..84ee965af4 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +{ + "name": "Stock Source Relocate - Dynamic Routing", + "summary": "Glue module", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "category": "Warehouse Management", + "version": "13.0.1.0.0", + "license": "AGPL-3", + "depends": ["stock_dynamic_routing", "stock_move_source_relocate"], + "demo": [], + "data": ["views/stock_routing_views.xml", "views/stock_source_relocate_views.xml"], + "auto_install": True, + "installable": True, + "development_status": "Alpha", +} diff --git a/stock_move_source_relocate_dynamic_routing/models/__init__.py b/stock_move_source_relocate_dynamic_routing/models/__init__.py new file mode 100644 index 0000000000..24fbae0f04 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_move +from . import stock_source_relocate +from . import stock_routing diff --git a/stock_move_source_relocate_dynamic_routing/models/stock_move.py b/stock_move_source_relocate_dynamic_routing/models/stock_move.py new file mode 100644 index 0000000000..6e2dcc61a2 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/models/stock_move.py @@ -0,0 +1,21 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _apply_source_relocate_rule(self, relocation, reserved_availability, roundings): + relocated = super( + StockMove, + # disable application of routing in write() method of + # stock_dynamic_routing, we'll apply it here whatever the state of + # the move is + self.with_context(__applying_routing_rule=True), + )._apply_source_relocate_rule(relocation, reserved_availability, roundings) + # restore the previous context without "__applying_routing_rule", otherwise + # it wouldn't properly apply the routing in chain in the further moves + relocated.with_context(self.env.context)._chain_apply_routing() + return relocated diff --git a/stock_move_source_relocate_dynamic_routing/models/stock_routing.py b/stock_move_source_relocate_dynamic_routing/models/stock_routing.py new file mode 100644 index 0000000000..a9a1313b7a --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/models/stock_routing.py @@ -0,0 +1,28 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, models + + +class StockRouting(models.Model): + + _inherit = "stock.routing" + + def action_view_source_relocate(self): + picking_types = self.mapped("picking_type_id") + routing = self.env["stock.routing"].search( + [("picking_type_id", "in", picking_types.ids)] + ) + context = self.env.context + if len(picking_types) == 1: + context = dict(context, default_picking_type_id=picking_types.id) + return { + "name": _("Source Relocation"), + "domain": [("id", "in", routing.ids)], + "res_model": "stock.source.relocate", + "type": "ir.actions.act_window", + "view_id": False, + "view_mode": "tree,form", + "limit": 20, + "context": context, + } diff --git a/stock_move_source_relocate_dynamic_routing/models/stock_source_relocate.py b/stock_move_source_relocate_dynamic_routing/models/stock_source_relocate.py new file mode 100644 index 0000000000..4e5a9d3e0c --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/models/stock_source_relocate.py @@ -0,0 +1,28 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, models + + +class StockSourceRelocate(models.Model): + + _inherit = "stock.source.relocate" + + def action_view_dynamic_routing(self): + picking_types = self.mapped("picking_type_id") + routing = self.env["stock.routing"].search( + [("picking_type_id", "in", picking_types.ids)] + ) + context = self.env.context + if len(picking_types) == 1: + context = dict(context, default_picking_type_id=picking_types.id) + return { + "name": _("Dynamic Routing"), + "domain": [("id", "in", routing.ids)], + "res_model": "stock.routing", + "type": "ir.actions.act_window", + "view_id": False, + "view_mode": "tree,form", + "limit": 20, + "context": context, + } diff --git a/stock_move_source_relocate_dynamic_routing/readme/CONTRIBUTORS.rst b/stock_move_source_relocate_dynamic_routing/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..48286263cd --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_move_source_relocate_dynamic_routing/readme/DESCRIPTION.rst b/stock_move_source_relocate_dynamic_routing/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..46f8a8f457 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Glue module between ``stock_move_source_relocate`` and +``stock_dynamic_routing``. diff --git a/stock_move_source_relocate_dynamic_routing/tests/__init__.py b/stock_move_source_relocate_dynamic_routing/tests/__init__.py new file mode 100644 index 0000000000..f46756f0a3 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/tests/__init__.py @@ -0,0 +1 @@ +from . import test_dynamic_relocate diff --git a/stock_move_source_relocate_dynamic_routing/tests/test_dynamic_relocate.py b/stock_move_source_relocate_dynamic_routing/tests/test_dynamic_relocate.py new file mode 100644 index 0000000000..2338048ab2 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/tests/test_dynamic_relocate.py @@ -0,0 +1,57 @@ +from odoo.addons.stock_move_source_relocate.tests.test_source_relocate import ( + SourceRelocateCommon, +) + + +class TestSourceRelocate(SourceRelocateCommon): + def test_relocate_with_routing(self): + """Check that routing is applied when a relocation happen""" + # Relocation: for unavailable move in Stock, relocate to Replenish + self._create_relocate_rule( + self.wh.lot_stock_id, self.loc_replenish, self.wh.pick_type_id + ) + # Routing: a move with source location in replenishment is classified + # in picking type Replenish + pick_type_replenish = self.env["stock.picking.type"].create( + { + "name": "Replenish", + "code": "internal", + "sequence_code": "R", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": self.loc_replenish.id, + "default_location_dest_id": self.wh.lot_stock_id.id, + } + ) + self.env["stock.routing"].create( + { + "location_id": self.loc_replenish.id, + "picking_type_id": self.wh.pick_type_id.id, + "rule_ids": [ + ( + 0, + 0, + {"method": "pull", "picking_type_id": pick_type_replenish.id}, + ) + ], + } + ) + move = self._create_single_move(self.product, self.wh.pick_type_id) + move._assign_picking() + move._action_assign() + self.assertRecordValues( + move, + [ + { + "state": "confirmed", + "product_qty": 10.0, + "reserved_availability": 0.0, + # routing changed the picking type + "picking_type_id": pick_type_replenish.id, + "location_id": self.loc_replenish.id, + } + ], + ) + # routing created a new move + self.assertTrue(move.move_dest_ids) diff --git a/stock_move_source_relocate_dynamic_routing/views/stock_routing_views.xml b/stock_move_source_relocate_dynamic_routing/views/stock_routing_views.xml new file mode 100644 index 0000000000..72f4880fc3 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/views/stock_routing_views.xml @@ -0,0 +1,19 @@ + + + + stock.routing.form + stock.routing + + +
+
+
+
+
diff --git a/stock_move_source_relocate_dynamic_routing/views/stock_source_relocate_views.xml b/stock_move_source_relocate_dynamic_routing/views/stock_source_relocate_views.xml new file mode 100644 index 0000000000..3b316ea0c7 --- /dev/null +++ b/stock_move_source_relocate_dynamic_routing/views/stock_source_relocate_views.xml @@ -0,0 +1,22 @@ + + + + stock.source.relocate.form + stock.source.relocate + + +
+
+
+
+