diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index ccf4843f075..233d0bf4354 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -128,22 +128,22 @@ class TestMrpOrder(TestMrpCommon): self.assertEqual(production_2.reservation_state, 'confirmed', 'Production order should be availability for waiting state') # Update Inventory - inventory_wizard = self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': self.product_2.id, - 'new_quantity': 2.0, + 'inventory_quantity': 2.0, + 'location_id': self.ref('stock.stock_location_14') }) - inventory_wizard.change_product_qty() production_2.action_assign() # check sub product availability state is partially available self.assertEqual(production_2.reservation_state, 'confirmed', 'Production order should be availability for partially available state') # Update Inventory - inventory_wizard = self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': self.product_2.id, - 'new_quantity': 5.0, + 'inventory_quantity': 5.0, + 'location_id': self.ref('stock.stock_location_14') }) - inventory_wizard.change_product_qty() production_2.action_assign() # check sub product availability state is assigned diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index e3a3b24352d..1b4f711b856 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -54,11 +54,11 @@ class TestProcurement(TestMrpCommon): # Product2 48 Unit # --------------------- # Update Inventory - inventory_wizard = self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': self.product_2.id, - 'new_quantity': 48, + 'inventory_quantity': 48, + 'location_id': self.warehouse.lot_stock_id.id, }) - inventory_wizard.change_product_qty() produce_product_4.action_assign() self.assertEqual(produce_product_4.product_qty, 8, "Wrong quantity of finish product.") self.assertEqual(produce_product_4.product_uom_id, self.uom_dozen, "Wrong quantity of finish product.") @@ -83,11 +83,11 @@ class TestProcurement(TestMrpCommon): # ------------------ # Update Inventory - inventory_wizard = self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': self.product_2.id, - 'new_quantity': 12, + 'inventory_quantity': 12, + 'location_id': self.warehouse.lot_stock_id.id, }) - inventory_wizard.change_product_qty() production_product_6.action_assign() # ------------------------------------ diff --git a/addons/mrp/tests/test_workorder_operation.py b/addons/mrp/tests/test_workorder_operation.py index 38d52c799c2..530abcab062 100644 --- a/addons/mrp/tests/test_workorder_operation.py +++ b/addons/mrp/tests/test_workorder_operation.py @@ -307,11 +307,11 @@ class TestWorkOrderProcess(TestMrpCommon): man_order = man_order_form.save() # reset quantities self.product_1.type = "product" - self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': self.product_1.id, - 'new_quantity': 0.0, + 'inventory_quantity': 0.0, 'location_id': self.warehouse_1.lot_stock_id.id, - }).change_product_qty() + }) (self.product_2 | self.product_4).write({ 'tracking': 'none', diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index 4d2c6409137..38de8fed2b7 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -40,7 +40,6 @@ 'report/product_templates.xml', 'report/product_packaging.xml', - 'wizard/stock_change_product_qty_views.xml', 'wizard/stock_picking_return_views.xml', 'wizard/stock_scheduler_compute_views.xml', 'wizard/stock_immediate_transfer_views.xml', diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 5186f5d4e2d..5582ab5839f 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -379,9 +379,6 @@ class Product(models.Model): res['fields']['qty_available']['string'] = _('Produced Qty') return res - def action_update_quantity_on_hand(self): - return self.product_tmpl_id.with_context({'default_product_id': self.id}).action_update_quantity_on_hand() - def action_view_routes(self): return self.mapped('product_tmpl_id').action_view_routes() @@ -408,12 +405,30 @@ class Product(models.Model): return action def action_open_quants(self): - self.ensure_one() - self.env['stock.quant']._quant_tasks() - action = self.env.ref('stock.product_open_quants').read()[0] location_domain = self._get_domain_locations()[0] - action['domain'] = expression.AND([[('product_id', '=', self.id)], location_domain]) - return action + domain = expression.AND([[('product_id', 'in', self.ids)], location_domain]) + self = self.with_context(hide_location=not self.user_has_groups('stock.group_stock_multi_locations')) + + # If user have rights to write on quant, we define the view as editable. + if self.user_has_groups('stock.group_stock_manager'): + self = self.with_context(inventory_mode=True) + # Set default location id if multilocations is inactive + if not self.user_has_groups('stock.group_stock_multi_locations'): + user_company = self.env.user.company_id + warehouse = self.env['stock.warehouse'].search( + [('company_id', '=', user_company.id)], limit=1 + ) + if warehouse: + self = self.with_context(default_location_id=warehouse.lot_stock_id.id) + # Set default product id if quants concern only one product + if len(self) == 1: + self = self.with_context( + default_product_id=self.id, + single_product=True + ) + else: + self = self.with_context(product_tmpl_id=self.product_tmpl_id.id) + return self.env['stock.quant']._get_quants_action(domain) @api.model def get_theoretical_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, to_uom=None): @@ -609,37 +624,8 @@ class ProductTemplate(models.Model): raise UserError(_("You can not change the type of a product that is currently reserved on a stock move. If you need to change the type, you should first unreserve the stock move.")) return super(ProductTemplate, self).write(vals) - def action_update_quantity_on_hand(self): - default_product_id = self.env.context.get('default_product_id', self.product_variant_id.id) - if self.env.user.user_has_groups('stock.group_stock_multi_locations') or (self.env.user.user_has_groups('stock.group_production_lot') and self.tracking != 'none'): - product_ref_name = self.name + ' - ' + datetime.today().strftime('%m/%d/%y') - ctx = {'default_filter': 'product', 'default_product_id': default_product_id, 'default_name': product_ref_name} - return { - 'type': 'ir.actions.act_window', - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'stock.inventory', - 'context': ctx, - } - else: - wiz = self.env['stock.change.product.qty'].create({'product_id': default_product_id}) - return { - 'name': _('Update quantity on hand'), - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'stock.change.product.qty', - 'target': 'new', - 'res_id': wiz.id, - 'context': {'default_product_id': self.env.context.get('default_product_id')} - } - def action_open_quants(self): - self.env['stock.quant']._quant_tasks() - products = self.mapped('product_variant_ids') - action = self.env.ref('stock.product_open_quants').read()[0] - location_domain = products._get_domain_locations()[0] - action['domain'] = expression.AND([[('product_id', 'in', products.ids)], location_domain]) - return action + return self.product_variant_ids.action_open_quants() def action_view_related_putaway_rules(self): self.ensure_one() diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index 43cbf15996c..6651a010d87 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -59,7 +59,7 @@ class ProductionLot(models.Model): self.product_qty = sum(quants.mapped('quantity')) def action_lot_open_quants(self): - self.env['stock.quant']._quant_tasks() - action = self.env.ref('stock.lot_open_quants').read()[0] - action['context'] = {'search_default_lot_id': self.id} - return action + self = self.with_context(search_default_lot_id=self.id) + if self.user_has_groups('stock.group_stock_manager'): + self = self.with_context(inventory_mode=True) + return self.env['stock.quant']._get_quants_action() diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index ad1926eb134..ad720afe45c 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -6,7 +6,7 @@ from psycopg2 import OperationalError, Error from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError from odoo.osv import expression -from odoo.tools.float_utils import float_compare, float_is_zero +from odoo.tools.float_utils import float_compare, float_is_zero, float_round import logging @@ -18,8 +18,22 @@ class StockQuant(models.Model): _description = 'Quants' _rec_name = 'product_id' + def _domain_location_id(self): + if not self._is_inventory_mode(): + return + return ['&', ('company_id', '=', self.env.user.company_id.id), ('usage', 'in', ['internal', 'transit'])] + + def _domain_product_id(self): + if not self._is_inventory_mode(): + return + domain = [('type', '=', 'product')] + if self.env.context.get('product_tmpl_id'): + domain = expression.AND([domain, [('product_tmpl_id', '=', self.env.context['product_tmpl_id'])]]) + return domain + product_id = fields.Many2one( 'product.product', 'Product', + domain=lambda self: self._domain_product_id(), ondelete='restrict', readonly=True, required=True) # so user can filter on template in webclient product_tmpl_id = fields.Many2one( @@ -32,6 +46,7 @@ class StockQuant(models.Model): string='Company', store=True, readonly=True) location_id = fields.Many2one( 'stock.location', 'Location', + domain=lambda self: self._domain_location_id(), auto_join=True, ondelete='restrict', readonly=True, required=True) lot_id = fields.Many2one( 'stock.production.lot', 'Lot/Serial Number', @@ -45,13 +60,93 @@ class StockQuant(models.Model): quantity = fields.Float( 'Quantity', help='Quantity of products in this quant, in the default unit of measure of the product', - readonly=True, required=True, oldname='qty') + readonly=True, oldname='qty') + inventory_quantity = fields.Float( + 'Inventoried Quantity', compute='_compute_inventory_quantity', + inverse='_set_inventory_quantity', groups='stock.group_stock_manager') reserved_quantity = fields.Float( 'Reserved Quantity', default=0.0, help='Quantity of reserved products in this quant, in the default unit of measure of the product', readonly=True, required=True) in_date = fields.Datetime('Incoming Date', readonly=True) + tracking = fields.Selection(related='product_id.tracking', readonly=True) + + @api.depends('quantity') + def _compute_inventory_quantity(self): + if not self._is_inventory_mode(): + return + for quant in self: + quant.inventory_quantity = quant.quantity + + def _set_inventory_quantity(self): + """ Inverse method to create stock move when `inventory_quantity` is set + (`inventory_quantity` is only accessible in inventory mode). + """ + if not self._is_inventory_mode(): + return + for quant in self: + # Get the quantity to create a move for. + rounding = quant.product_id.uom_id.rounding + diff = float_round(quant.inventory_quantity - quant.quantity, precision_rounding=rounding) + diff_float_compared = float_compare(diff, 0, precision_rounding=rounding) + # Create and vaidate a move so that the quant matches its `inventory_quantity`. + if diff_float_compared == 0: + continue + elif diff_float_compared > 0: + move_vals = self._get_inventory_move_values(diff, self.product_id.property_stock_inventory, self.location_id) + else: + move_vals = self._get_inventory_move_values(-diff, self.location_id, self.product_id.property_stock_inventory, out=True) + move = self.env['stock.move'].with_context(inventory_mode=False).create(move_vals) + move._action_done() + + @api.model + def create(self, vals): + """ Override to handle the "inventory mode" and create a quant as + superuser the conditions are met. + """ + if self._is_inventory_mode() and 'inventory_quantity' in vals: + allowed_fields = self._get_inventory_fields_create() + if any([field for field in vals.keys() if field not in allowed_fields]): + raise UserError(_("Quant's creation is restricted, you can't do this operation.")) + inventory_quantity = vals.pop('inventory_quantity') + + # Create an empty quant or write on a similar one. + product = self.env['product.product'].browse(vals['product_id']) + location = self.env['stock.location'].browse(vals['location_id']) + lot_id = self.env['stock.production.lot'].browse(vals.get('lot_id')) + package_id = self.env['stock.quant.package'].browse(vals.get('package_id')) + owner_id = self.env['res.partner'].browse(vals.get('owner_id')) + quant = self._gather(product, location, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) + if quant: + quant = quant[0] + else: + quant = self.sudo().create(vals) + # Set the `inventory_quantity` field to create the necessary move. + quant.inventory_quantity = inventory_quantity + return quant + return super(StockQuant, self).create(vals) + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + """ Override to handle the "inventory mode" and set the `inventory_quantity` + in view list grouped. + """ + result = super(StockQuant, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) + if self._is_inventory_mode(): + for record in result: + record['inventory_quantity'] = record.get('quantity', 0) + return result + + def write(self, vals): + """ Override to handle the "inventory mode" and create the inventory move. """ + if self._is_inventory_mode() and 'inventory_quantity' in vals: + allowed_fields = self._get_inventory_fields_write() + if any([field for field in vals.keys() if field not in allowed_fields]): + raise UserError(_("Quant's edition is restricted, you can't do this operation.")) + self = self.sudo() + return super(StockQuant, self).write(vals) + return super(StockQuant, self).write(vals) def action_view_stock_moves(self): self.ensure_one() @@ -180,6 +275,23 @@ class StockQuant(models.Model): else: return sum([available_quantity for available_quantity in availaible_quantities.values() if float_compare(available_quantity, 0, precision_rounding=rounding) > 0]) + @api.onchange('location_id', 'product_id', 'lot_id', 'package_id', 'owner_id') + def _onchange_location_or_product_id(self): + if self.lot_id and self.tracking == 'none': + self.lot_id = None + if self.product_id and self.location_id: + quants = self._gather(self.product_id, self.location_id, lot_id=self.lot_id, package_id=self.package_id, owner_id=self.owner_id, strict=True) + quantity_on_hand = sum(quants.mapped('quantity')) + reserved_quantity = sum(quants.mapped('reserved_quantity')) + values_to_update = { + 'quantity': quantity_on_hand, + 'reserved_quantity': reserved_quantity + } + # We update 'inventory_quantity' only if user didn't modify it + if self.inventory_quantity == self.quantity: + values_to_update['inventory_quantity'] = quantity_on_hand + self.update(values_to_update) + @api.model def _update_available_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, in_date=None): """ Increase or decrease `reserved_quantity` of a set of quants for a given set of @@ -341,6 +453,108 @@ class StockQuant(models.Model): self._merge_quants() self._unlink_zero_quants() + @api.model + def _is_inventory_mode(self): + """ Used to control whether a quant was written on or created during an + "inventory session", meaning a mode where we need to create the stock.move + record necessary to be consistent with the `inventory_quantity` field. + """ + return self.env.context.get('inventory_mode') is True and self.user_has_groups('stock.group_stock_manager') + + @api.model + def _get_inventory_fields_create(self): + """ Returns a list of fields user can edit when he want to create a quant in `inventory_mode`. + """ + return ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id', 'inventory_quantity'] + + @api.model + def _get_inventory_fields_write(self): + """ Returns a list of fields user can edit when he want to edit a quant in `inventory_mode`. + """ + return ['inventory_quantity'] + + def _get_inventory_move_values(self, qty, location_id, location_dest_id, out=False): + """ Called when user manually set a new quantity (via `inventory_quantity`) + just before creating the corresponding stock move. + + :param location_id: `stock.location` + :param location_dest_id: `stock.location` + :param out: boolean to set on True when the move go to inventory adjustment location. + :return: dict with all values needed to create a new `stock.move` with its move line. + """ + self.ensure_one() + return { + 'name': _('Product Quantity Updated'), + 'product_id': self.product_id.id, + 'product_uom': self.product_uom_id.id, + 'product_uom_qty': qty, + 'company_id': self.company_id.id, + 'state': 'confirmed', + 'location_id': location_id.id, + 'location_dest_id': location_dest_id.id, + 'move_line_ids': [(0, 0, { + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom_id.id, + 'qty_done': qty, + 'location_id': location_id.id, + 'location_dest_id': location_dest_id.id, + 'company_id': self.company_id.id, + 'lot_id': self.lot_id.id, + 'package_id': out and self.package_id.id or False, + 'result_package_id': (not out) and self.package_id.id or False, + 'owner_id': self.owner_id.id, + })] + } + + @api.model + def _get_quants_action(self, domain=None, extend=False): + """ Returns an action to open quant view. + Depending of the context (user have right to be inventory mode or not), + the list view will be editable or readonly. + + :param domain: List for the domain, empty by default. + :param extend: If True, enables form, graph and pivot views. False by default. + """ + self._quant_tasks() + action = { + 'name': _('Stock On Hand'), + 'view_type': 'tree', + 'view_mode': 'list', + 'res_model': 'stock.quant', + 'type': 'ir.actions.act_window', + 'context': self.env.context, + 'domain': domain or [], + 'help': """ +
No stock on hand
+This analysis gives you an overview on the current stock + level of your products.
+ """ + } + + if self._is_inventory_mode(): + action['view_id'] = self.env.ref('stock.view_stock_quant_tree_editable').id + else: + action['view_id'] = self.env.ref('stock.view_stock_quant_tree').id + # Enables form view in readonly list + action.update({ + 'view_mode': 'tree,form', + 'views': [ + (action['view_id'], 'list'), + (self.env.ref('stock.view_stock_quant_form').id, 'form'), + ], + }) + if extend: + action.update({ + 'view_mode': 'tree,form,pivot,graph', + 'views': [ + (action['view_id'], 'list'), + (self.env.ref('stock.view_stock_quant_form').id, 'form'), + (self.env.ref('stock.view_stock_quant_pivot').id, 'pivot'), + (self.env.ref('stock.stock_quant_view_graph').id, 'graph'), + ], + }) + return action + class QuantPackage(models.Model): """ Packages containing quants and/or other packages """ diff --git a/addons/stock/tests/__init__.py b/addons/stock/tests/__init__.py index ea985ba98a1..043b70ca572 100644 --- a/addons/stock/tests/__init__.py +++ b/addons/stock/tests/__init__.py @@ -5,6 +5,7 @@ from . import test_product from . import test_warehouse from . import test_stock_location_search from . import test_quant +from . import test_quant_inventory_mode from . import test_inventory from . import test_move from . import test_move2 diff --git a/addons/stock/tests/test_quant_inventory_mode.py b/addons/stock/tests/test_quant_inventory_mode.py new file mode 100644 index 00000000000..98f8d064cad --- /dev/null +++ b/addons/stock/tests/test_quant_inventory_mode.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import SavepointCase +from odoo.exceptions import AccessError, UserError + + +class TestEditableQuant(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestEditableQuant, cls).setUpClass() + + # Shortcut to call `stock.quant` with `inventory mode` set in the context + cls.Quant = cls.env['stock.quant'].with_context(inventory_mode=True) + + Product = cls.env['product.product'] + Location = cls.env['stock.location'] + cls.product = Product.create({ + 'name': 'Product A', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.product2 = Product.create({ + 'name': 'Product B', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.product_tracked_sn = Product.create({ + 'name': 'Product tracked by SN', + 'type': 'product', + 'tracking': 'serial', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.warehouse = Location.create({ + 'name': 'Warehouse', + 'usage': 'internal', + }) + cls.stock = Location.create({ + 'name': 'Stock', + 'usage': 'internal', + 'location_id': cls.warehouse.id, + }) + cls.room1 = Location.create({ + 'name': 'Room A', + 'usage': 'internal', + 'location_id': cls.stock.id, + }) + cls.room2 = Location.create({ + 'name': 'Room B', + 'usage': 'internal', + 'location_id': cls.stock.id, + }) + cls.inventory_loss = cls.product.property_stock_inventory + + def test_create_quant_1(self): + """ Create a new quant who don't exist yet. + """ + # Checks we don't have any quant for this product. + quants = self.env['stock.quant'].search([('product_id', '=', self.product.id)]) + self.assertEqual(len(quants), 0) + self.Quant.create({ + 'product_id': self.product.id, + 'location_id': self.stock.id, + 'inventory_quantity': 24 + }) + quants = self.env['stock.quant'].search([ + ('product_id', '=', self.product.id), + ('quantity', '>', 0), + ]) + # Checks we have now a quant, and also checks the quantity is equals to + # what we set in `inventory_quantity` field. + self.assertEqual(len(quants), 1) + self.assertEqual(quants.quantity, 24) + + stock_move = self.env['stock.move'].search([ + ('product_id', '=', self.product.id), + ]) + self.assertEqual(stock_move.location_id.id, self.inventory_loss.id) + self.assertEqual(stock_move.location_dest_id.id, self.stock.id) + + def test_create_quant_2(self): + """ Try to create a quant who already exist. + Must update the existing quant instead of creating a new one. + """ + # Creates a quants... + first_quant = self.Quant.create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'quantity': 12, + }) + quants = self.env['stock.quant'].search([ + ('product_id', '=', self.product.id), + ('quantity', '>', 0), + ]) + self.assertEqual(len(quants), 1) + # ... then try to create an another quant for the same product/location. + second_quant = self.Quant.create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'inventory_quantity': 24, + }) + quants = self.env['stock.quant'].search([ + ('product_id', '=', self.product.id), + ('quantity', '>', 0), + ]) + # Checks we still have only one quant, and first quant quantity was + # updated, and second quant had the same ID than the first quant. + self.assertEqual(len(quants), 1) + self.assertEqual(first_quant.quantity, 24) + self.assertEqual(first_quant.id, second_quant.id) + stock_move = self.env['stock.move'].search([ + ('product_id', '=', self.product.id), + ]) + self.assertEqual(len(stock_move), 1) + + def test_create_quant_3(self): + """ Try to create a quant with `inventory_quantity` but not in inventory mode. + Creates two quants not in inventory mode: + - One with `quantity` (this one must be OK) + - One with `inventory_quantity` (this one must be null) + """ + valid_quant = self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'quantity': 10, + }) + invalid_quant = self.env['stock.quant'].create({ + 'product_id': self.product2.id, + 'location_id': self.room1.id, + 'inventory_quantity': 20, + }) + self.assertEqual(valid_quant.quantity, 10) + self.assertEqual(invalid_quant.quantity, 0) + + def test_create_quant_4(self): + """ Try to create tree quants in inventory mode with `quantity` and/or `inventory_quantity`. + Creates two quants not in inventory mode: + - One with `quantity` (this one must be OK, but `inventory_mode` is useless here as it + doesn't enter in the inventory mode case and create quant as usual) + - One with `inventory_quantity` (this one must be OK) + - One with the two values (this one must raises an error as it enters in the inventory + mode but user can't edit directly `quantity` in inventory mode) + """ + valid_quant = self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'quantity': 10, + }) + inventoried_quant = self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product2.id, + 'location_id': self.room1.id, + 'inventory_quantity': 20, + }) + with self.assertRaises(UserError): + invalid_quant = self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product.id, + 'location_id': self.room2.id, + 'quantity': 10, + 'inventory_quantity': 20, + }) + self.assertEqual(valid_quant.quantity, 10) + self.assertEqual(inventoried_quant.quantity, 20) + + def test_edit_quant_1(self): + """ Increases manually quantity of a quant. + """ + quant = self.Quant.create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'quantity': 12, + }) + quant.inventory_quantity = 24 + self.assertEqual(quant.quantity, 24) + stock_move = self.env['stock.move'].search([ + ('product_id', '=', self.product.id), + ]) + self.assertEqual(stock_move.location_id.id, self.inventory_loss.id) + self.assertEqual(stock_move.location_dest_id.id, self.room1.id) + + def test_edit_quant_2(self): + """ Decreases manually quantity of a quant. + """ + quant = self.Quant.create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'quantity': 12, + }) + quant.inventory_quantity = 8 + self.assertEqual(quant.quantity, 8) + stock_move = self.env['stock.move'].search([ + ('product_id', '=', self.product.id), + ]) + self.assertEqual(stock_move.location_id.id, self.room1.id) + self.assertEqual(stock_move.location_dest_id.id, self.inventory_loss.id) + + def test_edit_quant_3(self): + """ Try to edit a record without the inventory mode. + Must raise an error. + """ + user_demo = self.env.ref('base.user_demo') + user_admin = self.env.ref('base.user_admin') + quant = self.Quant.create({ + 'product_id': self.product.id, + 'location_id': self.room1.id, + 'quantity': 12 + }) + self.assertEqual(quant.quantity, 12) + # Try to write on quant without permission + with self.assertRaises(AccessError): + quant.sudo(user_demo).write({'inventory_quantity': 8}) + self.assertEqual(quant.quantity, 12) + + # Try to write on quant with permission + quant.sudo(user_admin).write({'inventory_quantity': 8}) + self.assertEqual(quant.quantity, 8) diff --git a/addons/stock/tests/test_warehouse.py b/addons/stock/tests/test_warehouse.py index 1752af381a9..1cd942ae701 100644 --- a/addons/stock/tests/test_warehouse.py +++ b/addons/stock/tests/test_warehouse.py @@ -7,12 +7,11 @@ from odoo.tests import Form class TestWarehouse(TestStockCommon): def test_inventory_product(self): self.product_1.type = 'product' - inventory_wizard = self.env['stock.change.product.qty'].create({ + product_1_quant = self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': self.product_1.id, - 'new_quantity': 50.0, + 'inventory_quantity': 50.0, 'location_id': self.warehouse_1.lot_stock_id.id, }) - inventory_wizard.change_product_qty() inventory = self.env['stock.inventory'].sudo(self.user_stock_manager).create({ 'name': 'Starting for product_1', 'filter': 'product', @@ -47,32 +46,6 @@ class TestWarehouse(TestStockCommon): self.assertEqual(self.env['stock.quant']._gather(self.product_1, self.warehouse_1.wh_input_stock_loc_id).quantity, 0.0) self.assertEqual(self.env['stock.quant']._gather(self.product_1, self.env.ref('stock.stock_location_stock')).quantity, 0.0) - def test_inventory_wizard(self): - self.product_1.type = 'product' - inventory_wizard = self.env['stock.change.product.qty'].create({ - 'product_id': self.product_1.id, - 'new_quantity': 50.0, - 'location_id': self.warehouse_1.lot_stock_id.id, - }) - inventory_wizard.change_product_qty() - # Check inventory performed in setup was effectivley performed - self.assertEqual(self.product_1.virtual_available, 50.0) - self.assertEqual(self.product_1.qty_available, 50.0) - - # Check inventory obj details (1 inventory with 1 line, because 1 product change) - inventory = self.env['stock.inventory'].search([('id', 'not in', self.existing_inventories.ids)]) - self.assertEqual(len(inventory), 1) - self.assertIn('INV: %s' % self.product_1.display_name, inventory.name) - self.assertEqual(len(inventory.line_ids), 1) - self.assertEqual(inventory.line_ids.product_id, self.product_1) - self.assertEqual(inventory.line_ids.product_qty, 50.0) - - # Check associated quants: 2 quants for the product and the quantity (1 in stock, 1 in inventory adjustment) - quant = self.env['stock.quant'].search([('id', 'not in', self.existing_quants.ids)]) - self.assertEqual(len(quant), 2) - # print quant.name, quant.product_id, quant.location_id - # TDE TODO: expand this test - def test_basic_move(self): product = self.product_3.sudo(self.user_stock_manager) product.type = 'product' diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index 0df68691c59..e8e54d1b695 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -172,7 +172,6 @@