diff --git a/stock_move_location/README.rst b/stock_move_location/README.rst new file mode 100644 index 000000000000..21cd7854d5e2 --- /dev/null +++ b/stock_move_location/README.rst @@ -0,0 +1,21 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. diff --git a/stock_move_location/__init__.py b/stock_move_location/__init__.py new file mode 100644 index 000000000000..91ba63a47213 --- /dev/null +++ b/stock_move_location/__init__.py @@ -0,0 +1,6 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import wizard +from . import models diff --git a/stock_move_location/__manifest__.py b/stock_move_location/__manifest__.py new file mode 100644 index 000000000000..d9b725fc5ba2 --- /dev/null +++ b/stock_move_location/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Move Stock Location", + "version": "11.0.1.0.0", + "author": "Julius Network Solutions, " + "Odoo Community Association (OCA)", + "summary": "This module allows to move all stock " + "in a stock location to an other one.", + "website": "https://github.com/OCA/stock-logistics-warehouse", + 'license': 'AGPL-3', + "depends": [ + "stock", + ], + "category": "Stock", + "data": [ + 'wizard/stock_move_location.xml', + ], +} diff --git a/stock_move_location/i18n/stock_move_location.pot b/stock_move_location/i18n/stock_move_location.pot new file mode 100644 index 000000000000..ebbc50d73e90 --- /dev/null +++ b/stock_move_location/i18n/stock_move_location.pot @@ -0,0 +1,168 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_move_location +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-01-08 23:43+0000\n" +"PO-Revision-Date: 2019-01-08 23:43+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_move_location +#: model:ir.ui.view,arch_db:stock_move_location.view_wiz_stock_move_location_form_stock_move_location +msgid "Add all" +msgstr "" + +#. module: stock_move_location +#: model:ir.ui.view,arch_db:stock_move_location.view_wiz_stock_move_location_form_stock_move_location +msgid "Cancel" +msgstr "" + +#. module: stock_move_location +#: model:ir.ui.view,arch_db:stock_move_location.view_wiz_stock_move_location_form_stock_move_location +msgid "Clear all" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_create_uid +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_create_uid +msgid "Created by" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_create_date +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_create_date +msgid "Created on" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_destination_location_id +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_destination_location_id +msgid "Destination Location" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_display_name +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_display_name +msgid "Display Name" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_id +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_id +msgid "ID" +msgstr "" + +#. module: stock_move_location +#: model:ir.model,name:stock_move_location.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: stock_move_location +#: model:ir.ui.view,arch_db:stock_move_location.view_wiz_stock_move_location_form_stock_move_location +msgid "Inventory Details" +msgstr "" + +#. module: stock_move_location +#: model:ir.model,name:stock_move_location.model_stock_inventory_line +msgid "Inventory Line" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location___last_update +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line___last_update +msgid "Last Modified on" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_write_uid +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_write_date +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_write_date +msgid "Last Updated on" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_lot_id +msgid "Lot/Serial Number" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_max_quantity +msgid "Maximum available quantity" +msgstr "" + +#. module: stock_move_location +#: model:ir.ui.view,arch_db:stock_move_location.view_wiz_stock_move_location_form_stock_move_location +msgid "Move Location" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_stock_move_location_line_ids +msgid "Move Location lines" +msgstr "" + +#. module: stock_move_location +#: model:ir.actions.act_window,name:stock_move_location.wiz_stock_move_location_action +#: model:ir.ui.menu,name:stock_move_location.menuitem_move_location +msgid "Move from location..." +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_move_location_wizard_id +msgid "Move location Wizard" +msgstr "" + +#. module: stock_move_location +#: code:addons/stock_move_location/wizard/stock_move_location_line.py:56 +#, python-format +msgid "Move quantity can not exceed max quantity or be negative" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_origin_location_id +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_origin_location_id +msgid "Origin Location" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_product_id +msgid "Product" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_product_uom_id +msgid "Product Unit of Measure" +msgstr "" + +#. module: stock_move_location +#: model:ir.model.fields,field_description:stock_move_location.field_wiz_stock_move_location_line_move_quantity +msgid "Quantity to move" +msgstr "" + +#. module: stock_move_location +#: model:ir.ui.view,arch_db:stock_move_location.view_wiz_stock_move_location_form_stock_move_location +msgid "UoM" +msgstr "" + +#. module: stock_move_location +#: model:ir.model,name:stock_move_location.model_wiz_stock_move_location +msgid "wiz.stock.move.location" +msgstr "" + +#. module: stock_move_location +#: model:ir.model,name:stock_move_location.model_wiz_stock_move_location_line +msgid "wiz.stock.move.location.line" +msgstr "" + diff --git a/stock_move_location/models/__init__.py b/stock_move_location/models/__init__.py new file mode 100644 index 000000000000..d8a43735d3a3 --- /dev/null +++ b/stock_move_location/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import stock_move diff --git a/stock_move_location/models/stock_move.py b/stock_move_location/models/stock_move.py new file mode 100644 index 000000000000..b9c4883ea3f8 --- /dev/null +++ b/stock_move_location/models/stock_move.py @@ -0,0 +1,20 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + location_move = fields.Boolean( + string="Part of move location", + help="Wether this move is a part of stock_location moves", + ) + + @api.depends("location_move") + def _compute_show_details_visible(self): + super()._compute_show_details_visible() + for move in self: + if move.location_move: + move.show_details_visible = True diff --git a/stock_move_location/readme/CONTRIBUTORS.rst b/stock_move_location/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..012a547fecf5 --- /dev/null +++ b/stock_move_location/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Mathieu Vatel <mathieu@julius.fr> +* Mykhailo Panarin <m.panarin@mobilunity.com> diff --git a/stock_move_location/readme/DESCRIPTION.rst b/stock_move_location/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..7b0eeb0eedb5 --- /dev/null +++ b/stock_move_location/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module allows to move entire location of products from one place to another diff --git a/stock_move_location/readme/USAGE.rst b/stock_move_location/readme/USAGE.rst new file mode 100644 index 000000000000..60efc93b1404 --- /dev/null +++ b/stock_move_location/readme/USAGE.rst @@ -0,0 +1,10 @@ +* A new menuitem Stock > Move from location... opens a wizard + where 2 location ca be specified. +* Select origin and destination locations and press "IMMEDIATE TRANSFER" or "PLANNED TRANSFER" +* Press `ADD ALL` button to add all products available +* Those lines can be edited. Move quantity can't be more than a max available quantity +* Move doesn't care about the reservations and will move stuff anyway +* If during you operation with the wizard the real quantity will change + it will move only the available quantity at the button press +* Products will be moved and a form view of picking that did that will show up +* If "PLANNED TRANSFER" is used - the picking won't be validated automatically diff --git a/stock_move_location/tests/__init__.py b/stock_move_location/tests/__init__.py new file mode 100644 index 000000000000..00527a8b0772 --- /dev/null +++ b/stock_move_location/tests/__init__.py @@ -0,0 +1,6 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import test_common +from . import test_move_location diff --git a/stock_move_location/tests/test_common.py b/stock_move_location/tests/test_common.py new file mode 100644 index 000000000000..bb158ce15f5c --- /dev/null +++ b/stock_move_location/tests/test_common.py @@ -0,0 +1,93 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests import common + + +class TestsCommon(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.location_obj = cls.env["stock.location"] + product_obj = cls.env["product.product"] + cls.wizard_obj = cls.env["wiz.stock.move.location"] + cls.quant_obj = cls.env["stock.quant"] + + cls.internal_loc_1 = cls.location_obj.create({ + "name": "INT_1", + "usage": "internal", + "active": True, + }) + cls.internal_loc_2 = cls.location_obj.create({ + "name": "INT_2", + "usage": "internal", + "active": True, + }) + cls.uom_unit = cls.env.ref('product.product_uom_unit') + cls.product_no_lots = product_obj.create({ + "name": "Pineapple", + "type": "product", + "tracking": "none", + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.product_lots = product_obj.create({ + "name": "Pineapple", + "type": "product", + "tracking": "lot", + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.lot1 = cls.env['stock.production.lot'].create({ + 'product_id': cls.product_lots.id, + }) + cls.lot2 = cls.env['stock.production.lot'].create({ + 'product_id': cls.product_lots.id, + }) + cls.lot3 = cls.env['stock.production.lot'].create({ + 'product_id': cls.product_lots.id, + }) + + def setup_product_amounts(self): + self.set_product_amount( + self.product_no_lots, + self.internal_loc_1, + 123, + ) + self.set_product_amount( + self.product_lots, + self.internal_loc_1, + 1, + lot_id=self.lot1, + ) + self.set_product_amount( + self.product_lots, + self.internal_loc_1, + 1, + lot_id=self.lot2, + ) + self.set_product_amount( + self.product_lots, + self.internal_loc_1, + 1, + lot_id=self.lot3, + ) + + def set_product_amount(self, product, location, amount, lot_id=None): + self.env['stock.quant']._update_available_quantity( + product, + location, + amount, + lot_id=lot_id, + ) + + def check_product_amount(self, product, location, amount, lot_id=None): + self.assertEqual( + self.env['stock.quant']._get_available_quantity( + product, + location, + lot_id=lot_id, + ), + amount, + ) diff --git a/stock_move_location/tests/test_move_location.py b/stock_move_location/tests/test_move_location.py new file mode 100644 index 000000000000..2dec616e10d3 --- /dev/null +++ b/stock_move_location/tests/test_move_location.py @@ -0,0 +1,111 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_common import TestsCommon +from odoo.exceptions import ValidationError + + +class TestMoveLocation(TestsCommon): + + def _create_wizard(self, origin_location, destination_location): + return self.wizard_obj.create({ + "origin_location_id": origin_location.id, + "destination_location_id": destination_location.id, + }) + + def test_move_location_wizard(self): + """Test a simple move. + """ + self.setup_product_amounts() + wizard = self._create_wizard(self.internal_loc_1, self.internal_loc_2) + wizard.add_lines() + wizard.action_move_location() + self.check_product_amount( + self.product_no_lots, self.internal_loc_1, 0, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_1, 0, self.lot1, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_1, 0, self.lot1, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_1, 0, self.lot1, + ) + self.check_product_amount( + self.product_no_lots, self.internal_loc_2, 123, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_2, 1, self.lot1, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_2, 1, self.lot1, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_2, 1, self.lot1, + ) + + def test_move_location_wizard_amount(self): + """Can't move more than exists + """ + self.setup_product_amounts() + wizard = self._create_wizard(self.internal_loc_1, self.internal_loc_2) + wizard.add_lines() + with self.assertRaises(ValidationError): + wizard.stock_move_location_line_ids[0].move_quantity += 1 + + def test_move_location_wizard_ignore_reserved(self): + """Can't move more than exists + """ + self.setup_product_amounts() + wizard = self._create_wizard(self.internal_loc_1, self.internal_loc_2) + wizard.add_lines() + # reserve some quants + self.quant_obj._update_reserved_quantity( + self.product_no_lots, + self.internal_loc_1, + 50, + ) + self.quant_obj._update_reserved_quantity( + self.product_lots, + self.internal_loc_1, + 1, + lot_id=self.lot1, + ) + # doesn't care about reservations, everything is moved + wizard.action_move_location() + self.check_product_amount( + self.product_no_lots, self.internal_loc_1, 0, + ) + self.check_product_amount( + self.product_no_lots, self.internal_loc_2, 123, + ) + self.check_product_amount( + self.product_lots, self.internal_loc_2, 1, self.lot1, + ) + + def test_wizard_clear_lines(self): + """Test lines getting cleared properly + """ + self.setup_product_amounts() + wizard = self._create_wizard(self.internal_loc_1, self.internal_loc_2) + wizard.add_lines() + self.assertEqual(len(wizard.stock_move_location_line_ids), 4) + wizard._onchange_locations() + self.assertEqual(len(wizard.stock_move_location_line_ids), 0) + + def test_planned_transfer(self): + """Test planned transfer + """ + self.setup_product_amounts() + wizard = self._create_wizard(self.internal_loc_1, self.internal_loc_2) + wizard.add_lines() + wizard.with_context({'planned': True}).action_move_location() + picking = wizard.picking_id + self.assertEqual(picking.state, 'draft') + self.assertEqual(len(picking.move_line_ids), 4) + self.assertEqual( + sorted(picking.move_line_ids.mapped("qty_done")), + [1, 1, 1, 123], + ) diff --git a/stock_move_location/wizard/__init__.py b/stock_move_location/wizard/__init__.py new file mode 100644 index 000000000000..d9fdbf21cacb --- /dev/null +++ b/stock_move_location/wizard/__init__.py @@ -0,0 +1,6 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import stock_move_location +from . import stock_move_location_line diff --git a/stock_move_location/wizard/stock_move_location.py b/stock_move_location/wizard/stock_move_location.py new file mode 100644 index 000000000000..65c39134bc45 --- /dev/null +++ b/stock_move_location/wizard/stock_move_location.py @@ -0,0 +1,197 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from itertools import groupby + +from odoo import api, fields, models + + +class StockMoveLocationWizard(models.TransientModel): + _name = "wiz.stock.move.location" + + origin_location_id = fields.Many2one( + string='Origin Location', + comodel_name='stock.location', + required=True, + domain=lambda self: self._get_locations_domain(), + ) + destination_location_id = fields.Many2one( + string='Destination Location', + comodel_name='stock.location', + required=True, + domain=lambda self: self._get_locations_domain(), + ) + stock_move_location_line_ids = fields.One2many( + string="Move Location lines", + comodel_name="wiz.stock.move.location.line", + inverse_name="move_location_wizard_id", + ) + picking_id = fields.Many2one( + string="Connected Picking", + comodel_name="stock.picking", + ) + + @api.onchange('origin_location_id', 'destination_location_id') + def _onchange_locations(self): + self._clear_lines() + + @api.onchange("stock_move_location_line_ids") + def _onchange_stock_move_location_line_ids(self): + lines_to_update = self.stock_move_location_line_ids.filtered( + lambda x: x.custom is True and + not all([x.origin_location_id, x.destination_location_id]) + ) + lines_to_update.update({ + "origin_location_id": self.origin_location_id, + "destination_location_id": self.destination_location_id, + }) + # for an easier extension of this function + return lines_to_update + + def _clear_lines(self): + origin = self.origin_location_id + destination = self.destination_location_id + # there is `invalidate_cache` call inside the unlink + # which will clear the wizard - not cool. + # we have to keep the values somehow + self.stock_move_location_line_ids.unlink() + self.origin_location_id = origin + self.destination_location_id = destination + + def _get_locations_domain(self): + return [('usage', '=', 'internal')] + + def _create_picking(self): + return self.env['stock.picking'].create({ + 'picking_type_id': self.env.ref('stock.picking_type_internal').id, + 'location_id': self.origin_location_id.id, + 'location_dest_id': self.destination_location_id.id, + }) + + @api.multi + def group_lines(self): + sorted_lines = sorted( + self.stock_move_location_line_ids, + key=lambda x: x.product_id, + ) + groups = groupby(sorted_lines, key=lambda x: x.product_id) + groups_dict = {} + for prod, lines in groups: + groups_dict[prod.id] = list(lines) + return groups_dict + + @api.multi + def _create_moves(self, picking): + self.ensure_one() + groups = self.group_lines() + moves = self.env["stock.move"] + for group, lines in groups.items(): + move = self._create_move(picking, lines) + moves |= move + return moves + + def _get_move_values(self, picking, lines): + # locations are same for the products + location_from_id = lines[0].origin_location_id.id + location_to_id = lines[0].destination_location_id.id + product_id = lines[0].product_id.id + product_uom_id = lines[0].product_uom_id.id + qty = sum([x.move_quantity for x in lines]) + return { + "name": "test", + "location_id": location_from_id, + "location_dest_id": location_to_id, + "product_id": product_id, + "product_uom": product_uom_id, + "product_uom_qty": qty, + "picking_id": picking.id, + "location_move": True, + } + + @api.multi + def _create_move(self, picking, lines): + self.ensure_one() + move = self.env["stock.move"].create( + self._get_move_values(picking, lines), + ) + for line in lines: + line.create_move_lines(picking, move) + return move + + @api.multi + def action_move_location(self): + self.ensure_one() + picking = self._create_picking() + self._create_moves(picking) + if not self.env.context.get("planned"): + picking.action_confirm() + picking.action_assign() + picking.button_validate() + self.picking_id = picking + return self._get_picking_action(picking.id) + + def _get_picking_action(self, pickinig_id): + action = self.env.ref("stock.action_picking_tree_all").read()[0] + form_view = self.env.ref("stock.view_picking_form").id + action.update({ + "view_mode": "form", + "views": [(form_view, "form")], + "res_id": pickinig_id, + }) + return action + + def _get_group_quants_sql(self): + location_id = self.origin_location_id.id + company = self.env['res.company']._company_default_get( + 'stock.inventory', + ) + return """ + SELECT product_id, lot_id, SUM(quantity) + FROM stock_quant + WHERE location_id = {location_id} AND company_id = {company_id} + GROUP BY product_id, lot_id + """.format( + location_id=location_id, + company_id=company.id, + ) + + def _get_stock_move_location_lines_values(self): + product_obj = self.env['product.product'] + + # Using sql as search_group doesn't support aggregation functions + # leading to overhead in queries to DB + self.env.cr.execute(self._get_group_quants_sql()) + product_data = [] + for group in self.env.cr.dictfetchall(): + product = product_obj.browse(group.get("product_id")).exists() + product_data.append({ + 'product_id': product.id, + 'move_quantity': group.get("sum"), + 'max_quantity': group.get("sum"), + 'origin_location_id': self.origin_location_id.id, + 'destination_location_id': self.destination_location_id.id, + # cursor returns None instead of False + 'lot_id': group.get("lot_id") or False, + 'product_uom_id': product.uom_id.id, + 'move_location_wizard_id': self.id, + 'custom': False, + }) + return product_data + + def add_lines(self): + self.ensure_one() + if not self.stock_move_location_line_ids: + for line_val in self._get_stock_move_location_lines_values(): + if line_val.get('max_quantity') <= 0: + continue + self.env["wiz.stock.move.location.line"].create(line_val) + return { + "type": "ir.actions.do_nothing", + } + + def clear_lines(self): + self._clear_lines() + return { + "type": "ir.action.do_nothing", + } diff --git a/stock_move_location/wizard/stock_move_location.xml b/stock_move_location/wizard/stock_move_location.xml new file mode 100755 index 000000000000..6ae9831ed44b --- /dev/null +++ b/stock_move_location/wizard/stock_move_location.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="view_wiz_stock_move_location_form_stock_move_location" model="ir.ui.view"> + <field name="name">wiz.stock.move.location.form.stock_move_location</field> + <field name="model">wiz.stock.move.location</field> + <field name="arch" type="xml"> + <form> + <sheet> + <group name="main"> + <field name="origin_location_id"/> + <field name="destination_location_id"/> + </group> + <group name="button"> + <button name="add_lines" string="Add all" type="object" class="btn-primary"/> + <button name="clear_lines" string="Clear all" type="object" class="btn-primary"/> + </group> + <group name="lines"> + <field name="stock_move_location_line_ids" nolabel="1" > + <tree string="Inventory Details" editable="bottom" decoration-info="move_quantity != max_quantity" decoration-danger="(move_quantity < 0) or (move_quantity > max_quantity)"> + <field name="product_id" domain="[('type','=','product')]"/> + <field name="product_uom_id" string="UoM" groups="product.group_uom"/> + <field name="origin_location_id" readonly="1" /> + <field name="destination_location_id" readonly="1" /> + <field name="lot_id" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}" groups="stock.group_production_lot" options="{'no_create': True}"/> + <field name="move_quantity"/> + <field name="custom" invisible="1" /> + <field name="max_quantity" attrs="{'readonly': [('custom', '!=', True)]}" /> + </tree> + </field> + </group> + <footer> + <button name="action_move_location" string="Immediate Transfer" type="object" class="btn-primary"/> + <button name="action_move_location" string="Planned Transfer" type="object" class="btn-primary" context="{'planned': True}"/> + <button special="cancel" string="Cancel" class="btn-default"/> + </footer> + </sheet> + </form> + </field> + </record> + + <record id="wiz_stock_move_location_action" model="ir.actions.act_window"> + <field name="name">Move from location...</field> + <field name="res_model">wiz.stock.move.location</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + + <menuitem + id="menuitem_move_location" + string="Move from location..." + parent="stock.menu_stock_warehouse_mgmt" + action="wiz_stock_move_location_action" + sequence="99"/> + +</odoo> diff --git a/stock_move_location/wizard/stock_move_location_line.py b/stock_move_location/wizard/stock_move_location_line.py new file mode 100644 index 000000000000..02abadacced3 --- /dev/null +++ b/stock_move_location/wizard/stock_move_location_line.py @@ -0,0 +1,150 @@ +# Copyright (C) 2011 Julius Network Solutions SARL <contact@julius.fr> +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.addons import decimal_precision as dp +from odoo.exceptions import ValidationError +from odoo.tools import float_compare + + +class StockMoveLocationWizardLine(models.TransientModel): + _name = "wiz.stock.move.location.line" + + move_location_wizard_id = fields.Many2one( + string="Move location Wizard", + comodel_name="wiz.stock.move.location", + ondelete="cascade", + required=True, + ) + product_id = fields.Many2one( + string="Product", + comodel_name="product.product", + required=True, + ) + origin_location_id = fields.Many2one( + string='Origin Location', + comodel_name='stock.location', + ) + destination_location_id = fields.Many2one( + string='Destination Location', + comodel_name='stock.location', + ) + product_uom_id = fields.Many2one( + string='Product Unit of Measure', + comodel_name='product.uom', + ) + lot_id = fields.Many2one( + string='Lot/Serial Number', + comodel_name='stock.production.lot', + domain="[('product_id','=',product_id)]" + ) + move_quantity = fields.Float( + string="Quantity to move", + digits=dp.get_precision('Product Unit of Measure'), + ) + max_quantity = fields.Float( + string="Maximum available quantity", + digits=dp.get_precision('Product Unit of Measure'), + ) + custom = fields.Boolean( + string="Custom line", + default=True, + ) + + @api.model + def get_rounding(self): + return self.env.ref("product.decimal_product_uom").digits or 3 + + @api.constrains("max_quantity", "move_quantity") + def _constraint_max_move_quantity(self): + for record in self: + if (float_compare( + record.move_quantity, + record.max_quantity, self.get_rounding()) == 1 or + float_compare(record.move_quantity, 0.0, + self.get_rounding()) == -1): + raise ValidationError(_( + "Move quantity can not exceed max quantity or be negative" + )) + + def create_move_lines(self, picking, move): + for line in self: + values = line._get_move_line_values(picking, move) + if values.get("qty_done") <= 0: + continue + self.env["stock.move.line"].create( + values + ) + return True + + @api.multi + def _get_move_line_values(self, picking, move): + self.ensure_one() + return { + "product_id": self.product_id.id, + "lot_id": self.lot_id.id, + "location_id": self.origin_location_id.id, + "location_dest_id": self.destination_location_id.id, + "qty_done": self._get_available_quantity(), + "product_uom_id": self.product_uom_id.id, + "picking_id": picking.id, + "move_id": move.id, + } + + def _get_available_quantity(self): + """We check here if the actual amount changed in the stock. + + We don't care about the reservations but we do care about not moving + more than exists.""" + self.ensure_one() + if not self.product_id: + return 0 + if self.env.context.get("planned"): + # for planned transfer we don't care about the amounts at all + return self.move_quantity + # switched to sql here to improve performance and lower db queries + self.env.cr.execute(self._get_specific_quants_sql()) + available_qty = self.env.cr.fetchone() + if not available_qty: + # if it is immediate transfer and product doesn't exist in that + # location -> make the transfer of 0. + return 0 + available_qty = available_qty[0] + if float_compare( + available_qty, + self.move_quantity, self.get_rounding()) == -1: + return available_qty + return self.move_quantity + + def _get_specific_quants_sql(self): + self.ensure_one() + lot = "AND lot_id = {}".format(self.lot_id.id) + if not self.lot_id: + lot = "AND lot_id is null" + return """ + SELECT sum(quantity) + FROM stock_quant + WHERE location_id = {location} + {lot} + AND product_id = {product} + GROUP BY location_id, product_id, lot_id + """.format( + location=self.origin_location_id.id, + product=self.product_id.id, + lot=lot, + ) + + @api.model + def create(self, vals): + res = super().create(vals) + # update of wizard lines is extremely buggy + # so i have to handle this additionally in create + if not all([res.origin_location_id, res.destination_location_id]): + or_loc_id = res.move_location_wizard_id.origin_location_id.id + des_loc_id = res.move_location_wizard_id.destination_location_id.id + res.write({ + "origin_location_id": or_loc_id, + "destination_location_id": des_loc_id, + }) + return res