diff --git a/stock_inventory_lockdown/README.rst b/stock_inventory_lockdown/README.rst new file mode 100644 index 000000000..a8dd6b5cd --- /dev/null +++ b/stock_inventory_lockdown/README.rst @@ -0,0 +1,63 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====================================== +Lock down locations during inventories +====================================== +This module lets you lock down the locations during an inventory. + +Usage +===== + +.. image:: images/location_locked.png + :alt: Error message + +.. image:: images/move_error.png + :alt: Error message + +While an inventory is in the state "In progress", no stock moves +can be recorded in/out of the inventory's location: users will get an error +message. +Creating or modifying a locations is also forbidden. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/153/8.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed `feedback +`_. + +Credits +======= + +Contributors +------------ + +* Loïc Bellier (Numérigraphe) +* Lionel Sausin (Numérigraphe) +* Laetitia Gangloff (Acsone) + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/stock_inventory_lockdown/__init__.py b/stock_inventory_lockdown/__init__.py new file mode 100644 index 000000000..ef0c464f0 --- /dev/null +++ b/stock_inventory_lockdown/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/stock_inventory_lockdown/__openerp__.py b/stock_inventory_lockdown/__openerp__.py new file mode 100644 index 000000000..09bca4a4b --- /dev/null +++ b/stock_inventory_lockdown/__openerp__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Inventory lock down", + "summary": "Lock down stock locations during inventories.", + "version": "8.0.1.0.0", + "depends": ["stock"], + "author": u"Numérigraphe,Odoo Community Association (OCA)", + "category": "Warehouse Management", + "images": [ + "images/move_error.png", + "images/location_locked.png"], + 'license': 'AGPL-3', + "installable": True, +} diff --git a/stock_inventory_lockdown/i18n/fr.po b/stock_inventory_lockdown/i18n/fr.po new file mode 100644 index 000000000..2dcb99f55 --- /dev/null +++ b/stock_inventory_lockdown/i18n/fr.po @@ -0,0 +1,32 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_inventory_lockdown +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-03-21 18:49+0000\n" +"PO-Revision-Date: 2016-03-21 18:49+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_inventory_lockdown +#: code:addons/stock_inventory_lockdown/models/stock_quant.py:42 +#: code:addons/stock_inventory_lockdown/models/stock_quant.py:56 +#, python-format +msgid "An inventory is being conducted at the following location(s):\n" +"%s" +msgstr "Un inventaire est en cours aux emplacements suivants :\n" +"%s" + +#. module: stock_inventory_lockdown +#: code:addons/stock_inventory_lockdown/models/stock_location.py:22 +#, python-format +msgid "An inventory is being conducted at this location" +msgstr "Un inventaire est en cours à cet emplacement" + diff --git a/stock_inventory_lockdown/i18n/stock_inventory_lockdown.pot b/stock_inventory_lockdown/i18n/stock_inventory_lockdown.pot new file mode 100644 index 000000000..10e6d1ee2 --- /dev/null +++ b/stock_inventory_lockdown/i18n/stock_inventory_lockdown.pot @@ -0,0 +1,31 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_inventory_lockdown +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-03-21 18:49+0000\n" +"PO-Revision-Date: 2016-03-21 18:49+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_inventory_lockdown +#: code:addons/stock_inventory_lockdown/models/stock_quant.py:42 +#: code:addons/stock_inventory_lockdown/models/stock_quant.py:56 +#, python-format +msgid "An inventory is being conducted at the following location(s):\n" +"%s" +msgstr "" + +#. module: stock_inventory_lockdown +#: code:addons/stock_inventory_lockdown/models/stock_location.py:22 +#, python-format +msgid "An inventory is being conducted at this location" +msgstr "" + diff --git a/stock_inventory_lockdown/images/location_locked.png b/stock_inventory_lockdown/images/location_locked.png new file mode 100644 index 000000000..fd4448561 Binary files /dev/null and b/stock_inventory_lockdown/images/location_locked.png differ diff --git a/stock_inventory_lockdown/images/move_error.png b/stock_inventory_lockdown/images/move_error.png new file mode 100644 index 000000000..9d420f9b2 Binary files /dev/null and b/stock_inventory_lockdown/images/move_error.png differ diff --git a/stock_inventory_lockdown/models/__init__.py b/stock_inventory_lockdown/models/__init__.py new file mode 100644 index 000000000..6062634c0 --- /dev/null +++ b/stock_inventory_lockdown/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import stock_quant +from . import stock_inventory +from . import stock_location diff --git a/stock_inventory_lockdown/models/stock_inventory.py b/stock_inventory_lockdown/models/stock_inventory.py new file mode 100644 index 000000000..4886be466 --- /dev/null +++ b/stock_inventory_lockdown/models/stock_inventory.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, api + + +class StockInventory(models.Model): + _inherit = 'stock.inventory' + + @api.model + def _get_locations_open_inventories(self): + """IDs of location in open exhaustive inventories, with children""" + inventories = self.search([('state', '=', 'confirm')]) + if not inventories: + # Early exit if no match found + return [] + location_ids = inventories.mapped('location_id') + + # Extend to the children Locations + return self.env['stock.location'].search( + [('location_id', 'child_of', location_ids.ids), + ('usage', 'in', ['internal', 'transit'])]) + + @api.multi + def action_done(self): + """Add value in the context to ignore the lockdown""" + return super(StockInventory, + self.with_context(bypass_lockdown=True)).action_done() diff --git a/stock_inventory_lockdown/models/stock_location.py b/stock_inventory_lockdown/models/stock_location.py new file mode 100644 index 000000000..a15b8210f --- /dev/null +++ b/stock_inventory_lockdown/models/stock_location.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# © 2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, api, _ +from openerp.exceptions import ValidationError + + +class StockLocation(models.Model): + """Refuse changes during exhaustive Inventories""" + _inherit = 'stock.location' + _order = 'name' + + @api.multi + def _check_inventory(self): + """Error if an inventory is being conducted here""" + location_inventory_open_ids = self.env['stock.inventory'].sudo( + )._get_locations_open_inventories() + for location in self: + if location in location_inventory_open_ids: + raise ValidationError( + _('An inventory is being conducted at this ' + 'location')) + + @api.multi + def write(self, vals): + """Refuse write if an inventory is being conducted""" + locations_to_check = self + # If changing the parent, no inventory must conducted there either + if vals.get('location_id'): + locations_to_check |= self.browse(vals['location_id']) + locations_to_check._check_inventory() + return super(StockLocation, self).write(vals) + + @api.model + def create(self, vals): + """Refuse create if an inventory is being conducted at the parent""" + if 'location_id' in vals: + self.browse(vals['location_id'])._check_inventory() + return super(StockLocation, self).create(vals) + + @api.multi + def unlink(self): + """Refuse unlink if an inventory is being conducted""" + self._check_inventory() + return super(StockLocation, self).unlink() diff --git a/stock_inventory_lockdown/models/stock_quant.py b/stock_inventory_lockdown/models/stock_quant.py new file mode 100644 index 000000000..ed3dbb3ad --- /dev/null +++ b/stock_inventory_lockdown/models/stock_quant.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# © 2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, api, _ +from openerp.exceptions import ValidationError + + +class StockQuant(models.Model): + _inherit = 'stock.quant' + + @api.multi + def write(self, vals): + """Check that the location is not locked by an open inventory. + Check both the location as it was (source) and the location as + it will be (destination). + We verify the locations even if they are unchanged, because changing + ie. the quantity is not acceptable either. + @raise ValidationError if they are. + """ + if not self.env.context.get('bypass_lockdown', False): + # Find the locked locations + locked_location_ids = self.env[ + 'stock.inventory']._get_locations_open_inventories() + if locked_location_ids and 'location_id' in vals.keys(): + messages = set() + # Find the destination locations + location_dest_id = self.env['stock.location'].browse( + vals['location_id']) + for quant in self: + # Source locations + location_id = quant.location_id + # Moving to a location locked down + if location_dest_id in locked_location_ids: + messages.add(location_dest_id.name) + # Moving from a location locked down + if location_id in locked_location_ids: + messages.add(location_id.name) + if len(messages): + raise ValidationError( + _('An inventory is being conducted at the following ' + 'location(s):\n%s') % "\n - ".join(messages)) + return super(StockQuant, self).write(vals) + + @api.model + def create(self, vals): + """Check that the locations are not locked by an open inventory. + @raise ValidationError if they are. + """ + quant = super(StockQuant, self).create(vals) + if not self.env.context.get('bypass_lockdown', False): + locked_location_ids = self.env[ + 'stock.inventory']._get_locations_open_inventories() + if quant.location_id in locked_location_ids: + raise ValidationError( + _('An inventory is being conducted at the following ' + 'location(s):\n%s') % " - " + quant.location_id.name) + return quant diff --git a/stock_inventory_lockdown/tests/__init__.py b/stock_inventory_lockdown/tests/__init__.py new file mode 100644 index 000000000..c1942bfd6 --- /dev/null +++ b/stock_inventory_lockdown/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_stock_inventory_lockdown diff --git a/stock_inventory_lockdown/tests/test_stock_inventory_lockdown.py b/stock_inventory_lockdown/tests/test_stock_inventory_lockdown.py new file mode 100644 index 000000000..905aad629 --- /dev/null +++ b/stock_inventory_lockdown/tests/test_stock_inventory_lockdown.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# © 2014 Acsone SA/NV (http://www.acsone.eu) +# © 2016 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp.exceptions import ValidationError + +from openerp.addons.stock.tests.common import TestStockCommon + + +class StockInventoryLocationTest(TestStockCommon): + def setUp(self): + super(StockInventoryLocationTest, self).setUp() + # Make a new location + self.new_location = self.env['stock.location'].create( + {'name': 'Test location', + 'usage': 'internal'}) + self.new_sublocation = self.env['stock.location'].create( + {'name': 'Test sublocation', + 'usage': 'internal', + 'location_id': self.new_location.id}) + # Input goods + self.env['stock.quant'].create( + {'location_id': self.new_location.id, + 'product_id': self.productA.id, + 'qty': 10.0}) + # Prepare an inventory + self.inventory = self.env['stock.inventory'].create( + {'name': 'Lock down location', + 'filter': 'none', + 'location_id': self.new_location.id}) + self.inventory.prepare_inventory() + self.assertTrue(self.inventory.line_ids, 'The inventory is empty.') + + def test_update_parent_location(self): + """Updating the parent of a location is OK if no inv. in progress.""" + self.inventory.action_cancel_inventory() + self.inventory.location_id.location_id = self.env.ref( + 'stock.stock_location_4') + + def test_update_parent_location_locked_down(self): + """Updating the parent of a location must fail""" + with self.assertRaises(ValidationError): + self.inventory.location_id.location_id = self.env.ref( + 'stock.stock_location_4') + + def test_inventory(self): + """We must still be able to finish the inventory""" + self.assertTrue(self.inventory.line_ids) + self.inventory.line_ids.write({'product_qty': 42.0}) + for line in self.inventory.line_ids: + self.assertNotEqual(line.product_id.with_context( + location=line.location_id.id).qty_available, 42.0) + self.inventory.action_done() + for line in self.inventory.line_ids: + self.assertEqual(line.product_id.with_context( + location=line.location_id.id).qty_available, 42.0) + + def test_inventory_sublocation(self): + """We must be able to make an inventory in a sublocation""" + inventory_subloc = self.env['stock.inventory'].create( + {'name': 'Lock down location', + 'filter': 'partial', + 'location_id': self.new_sublocation.id}) + inventory_subloc.prepare_inventory() + line = self.env['stock.inventory.line'].create( + {'product_id': self.productA.id, + 'product_qty': 22.0, + 'location_id': self.new_sublocation.id, + 'inventory_id': inventory_subloc.id}) + self.assertTrue(inventory_subloc.line_ids) + inventory_subloc.action_done() + self.assertEqual(line.product_id.with_context( + location=line.location_id.id).qty_available, 22.0) + + def test_move(self): + """Stock move must be forbidden during inventory""" + move = self.env['stock.move'].create({ + 'name': 'Test move lock down', + 'product_id': self.productA.id, + 'product_uom_qty': 10.0, + 'product_uom': self.productA.uom_id.id, + 'location_id': self.inventory.location_id.id, + 'location_dest_id': self.customer_location + }) + with self.assertRaises(ValidationError): + move.action_done()