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:
"
+ "
the available part is adjusted to the reserved quantity,"
+ " and its source location stays the same
"
+ "
the unavailable part is split in a new move in the"
+ " relocation location
"
+ "
"
+ )
+ # 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
+
+
+
+
+