diff options
| -rw-r--r-- | addons/mrp/tests/test_order.py | 12 | ||||
| -rw-r--r-- | addons/mrp/tests/test_procurement.py | 12 | ||||
| -rw-r--r-- | addons/mrp/tests/test_workorder_operation.py | 6 | ||||
| -rw-r--r-- | addons/stock/__manifest__.py | 1 | ||||
| -rw-r--r-- | addons/stock/models/product.py | 62 | ||||
| -rw-r--r-- | addons/stock/models/stock_production_lot.py | 8 | ||||
| -rw-r--r-- | addons/stock/models/stock_quant.py | 218 | ||||
| -rw-r--r-- | addons/stock/tests/__init__.py | 1 | ||||
| -rw-r--r-- | addons/stock/tests/test_quant_inventory_mode.py | 215 | ||||
| -rw-r--r-- | addons/stock/tests/test_warehouse.py | 31 | ||||
| -rw-r--r-- | addons/stock/views/product_views.xml | 6 | ||||
| -rw-r--r-- | addons/stock/views/stock_production_lot_views.xml | 6 | ||||
| -rw-r--r-- | addons/stock/views/stock_quant_views.xml | 51 | ||||
| -rw-r--r-- | addons/stock/wizard/__init__.py | 1 | ||||
| -rw-r--r-- | addons/stock/wizard/stock_change_product_qty.py | 95 | ||||
| -rw-r--r-- | addons/stock/wizard/stock_change_product_qty_views.xml | 28 | ||||
| -rw-r--r-- | addons/stock/wizard/stock_quantity_history.py | 25 | ||||
| -rw-r--r-- | addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py | 10 |
18 files changed, 545 insertions, 243 deletions
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': """ + <p class="o_view_nocontent_empty_folder">No stock on hand</p> + <p>This analysis gives you an overview on the current stock + level of your products.</p> + """ + } + + 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 @@ <field name="arch" type="xml"> <sheet position="before"> <header> - <button name="action_update_quantity_on_hand" string="Update Qty On Hand" type="object" attrs="{'invisible': [('type', '!=', 'product')]}"/> <button string="Replenish" type="action" name="%(action_product_replenish)d" context="{'default_product_id': id}" @@ -198,10 +197,6 @@ <field name="arch" type="xml"> <data> <header position="inside"> - <button string="Update Qty On Hand" - type="object" - name="action_update_quantity_on_hand" - attrs="{'invisible': [('type', '!=', 'product')]}"/> <button string="Replenish" type="action" name="%(action_product_replenish)d" context="{'default_product_id': id}" @@ -292,7 +287,6 @@ <field name="arch" type="xml"> <data> <header position="inside" > - <button name="action_update_quantity_on_hand" string="Update Qty On Hand" type="object" attrs="{'invisible': [('type', '!=', 'product')]}"/> <button string="Replenish" type="action" name="%(action_product_replenish)d" context="{'default_product_tmpl_id': id}" diff --git a/addons/stock/views/stock_production_lot_views.xml b/addons/stock/views/stock_production_lot_views.xml index 2d813e23a1d..2eaed78dac4 100644 --- a/addons/stock/views/stock_production_lot_views.xml +++ b/addons/stock/views/stock_production_lot_views.xml @@ -1,11 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <record model="ir.actions.act_window" id="lot_open_quants"> - <field name="name">Quants</field> - <field name="context">{'search_default_lot_id': active_id}</field> - <field name="res_model">stock.quant</field> - </record> - <record id="view_production_lot_form" model="ir.ui.view"> <field name="name">stock.production.lot.form</field> <field name="model">stock.production.lot</field> diff --git a/addons/stock/views/stock_quant_views.xml b/addons/stock/views/stock_quant_views.xml index 24abd75be41..38286c6bff3 100644 --- a/addons/stock/views/stock_quant_views.xml +++ b/addons/stock/views/stock_quant_views.xml @@ -69,19 +69,57 @@ </field> </record> + <record model="ir.ui.view" id="view_stock_quant_tree_editable"> + <field name="name">stock.quant.tree.editable</field> + <field name="model">stock.quant</field> + <field eval="10" name="priority"/> + <field name="arch" type="xml"> + <tree decoration-danger="inventory_quantity < 0" editable="top" + create="1" edit="1"> + <field name="id" invisible="1"/> + <field name="tracking" invisible="1"/> + <field name="product_id" attrs="{'readonly': [('id', '!=', False)]}" + readonly="context.get('single_product', False)" force_save="1" + options="{'no_create': True}"/> + <field name="location_id" attrs="{'readonly': [('id', '!=', False)]}" + invisible="context.get('hide_location', False)" + options="{'no_create': True}"/> + <field name="lot_id" groups="stock.group_production_lot" + attrs="{ + 'readonly': ['|', ('id', '!=', False), ('tracking', 'not in', ['serial', 'lot'])], + 'required': [('tracking', '!=', 'none')] + }" + context="{'default_product_id': product_id}" + domain="[('product_id', '=', product_id)]"/> + <field name="package_id" groups="stock.group_tracking_lot" + attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="owner_id" groups="stock.group_tracking_owner" + attrs="{'readonly': [('id', '!=', False)]}" + options="{'no_create': True}"/> + <field name="reserved_quantity"/> + <field name="quantity" string="On Hand Quantity" + invisible="1"/> + <field name="inventory_quantity" string="On Hand Quantity" + readonly="0"/> + <field name="product_uom_id" groups="uom.group_uom"/> + <field name='company_id' groups="base.group_multi_company"/> + </tree> + </field> + </record> + <record model="ir.ui.view" id="view_stock_quant_tree"> <field name="name">stock.quant.tree</field> <field name="model">stock.quant</field> <field eval="10" name="priority"/> <field name="arch" type="xml"> - <tree string="Inventory Valuation" create="0" delete="0" decoration-danger="quantity < 0"> + <tree create="0" delete="0" decoration-danger="quantity < 0"> <field name="product_id"/> - <field name="location_id" options="{'no_create': True}"/> + <field name="location_id" invisible="context.get('hide_location', False)"/> <field name="lot_id" groups="stock.group_production_lot"/> <field name="package_id" groups="stock.group_tracking_lot"/> <field name="owner_id" groups="stock.group_tracking_owner"/> - <field name="reserved_quantity" string="Reserved"/> - <field name="quantity" string="On Hand"/> + <field name="reserved_quantity"/> + <field name="quantity"/> <field name="product_uom_id" groups="uom.group_uom"/> <field name='company_id' groups="base.group_multi_company"/> </tree> @@ -136,11 +174,6 @@ <field name="domain">[('product_id.product_tmpl_id', '=', active_id)]</field> <field name="res_model">stock.quant</field> </record> - <record model="ir.actions.act_window" id="product_open_quants"> <!-- product_view + python --> - <field name="name">Stock On Hand</field> - <field name="domain">[('product_id', '=', active_id)]</field> - <field name="res_model">stock.quant</field> - </record> <record model="ir.actions.act_window" id="location_open_quants"> <!-- Used in location --> <field name="context">{'search_default_productgroup': 1}</field> <field name="domain">[('location_id', 'child_of', active_ids)]</field> diff --git a/addons/stock/wizard/__init__.py b/addons/stock/wizard/__init__.py index 8a139729a06..85863432c77 100644 --- a/addons/stock/wizard/__init__.py +++ b/addons/stock/wizard/__init__.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import stock_picking_return -from . import stock_change_product_qty from . import stock_scheduler_compute from . import stock_immediate_transfer from . import stock_backorder_confirmation diff --git a/addons/stock/wizard/stock_change_product_qty.py b/addons/stock/wizard/stock_change_product_qty.py deleted file mode 100644 index 693c56067b2..00000000000 --- a/addons/stock/wizard/stock_change_product_qty.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models, fields, tools, _ -from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError - - -class ProductChangeQuantity(models.TransientModel): - _name = "stock.change.product.qty" - _description = "Change Product Quantity" - - # TDE FIXME: strange dfeault method, was present before migration ? to check - product_id = fields.Many2one('product.product', 'Product', required=True) - product_tmpl_id = fields.Many2one('product.template', 'Template', required=True) - product_variant_count = fields.Integer('Variant Count', related='product_tmpl_id.product_variant_count', readonly=False) - new_quantity = fields.Float( - 'New Quantity on Hand', default=1, - digits=dp.get_precision('Product Unit of Measure'), required=True, - help='This quantity is expressed in the Default Unit of Measure of the product.') - location_id = fields.Many2one('stock.location', 'Location', required=True, domain="[('usage', '=', 'internal')]") - - @api.model - def default_get(self, fields): - res = super(ProductChangeQuantity, self).default_get(fields) - if 'location_id' in fields and not res.get('location_id'): - company_user = self.env.company - warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_user.id)], limit=1) - if warehouse: - res['location_id'] = warehouse.lot_stock_id.id - return res - - @api.onchange('location_id', 'product_id') - def onchange_location_id(self): - # TDE FIXME: should'nt we use context / location ? - if self.location_id and self.product_id: - availability = self.product_id.with_context(compute_child=False)._product_available() - self.new_quantity = availability[self.product_id.id]['qty_available'] - - @api.onchange('product_id') - def onchange_product_id(self): - if self.product_id: - self.product_tmpl_id = self.onchange_product_id_dict(self.product_id.id)['product_tmpl_id'] - - def _action_start_line(self): - product = self.product_id.with_context(location=self.location_id.id) - th_qty = product.qty_available - - res = { - 'product_qty': self.new_quantity, - 'location_id': self.location_id.id, - 'product_id': self.product_id.id, - 'product_uom_id': self.product_id.uom_id.id, - 'theoretical_qty': th_qty, - } - - return res - - - def onchange_product_id_dict(self, product_id): - return { - 'product_tmpl_id': self.env['product.product'].browse(product_id).product_tmpl_id.id, - } - - @api.model - def create(self, values): - if values.get('product_id'): - values.update(self.onchange_product_id_dict(values['product_id'])) - return super(ProductChangeQuantity, self).create(values) - - @api.constrains('new_quantity') - def check_new_quantity(self): - if any(wizard.new_quantity < 0 for wizard in self): - raise UserError(_('Quantity cannot be negative.')) - - def change_product_qty(self): - """ Changes the Product Quantity by making a Physical Inventory. """ - Inventory = self.env['stock.inventory'] - for wizard in self: - product = wizard.product_id.with_context(location=wizard.location_id.id) - line_data = wizard._action_start_line() - - if wizard.product_id.id: - inventory_filter = 'product' - else: - inventory_filter = 'none' - inventory = Inventory.create({ - 'name': _('INV: %s') % tools.ustr(wizard.product_id.display_name), - 'filter': inventory_filter, - 'product_id': wizard.product_id.id, - 'location_id': wizard.location_id.id, - 'line_ids': [(0, 0, line_data)], - }) - inventory.action_validate() - return {'type': 'ir.actions.act_window_close'} diff --git a/addons/stock/wizard/stock_change_product_qty_views.xml b/addons/stock/wizard/stock_change_product_qty_views.xml deleted file mode 100644 index 496be2ea8f8..00000000000 --- a/addons/stock/wizard/stock_change_product_qty_views.xml +++ /dev/null @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <data> - <record id="view_change_product_quantity" model="ir.ui.view"> - <field name="name">Change Product Quantity</field> - <field name="model">stock.change.product.qty</field> - <field name="arch" type="xml"> - <form string="Update Product Quantity"> - <group> - <field name="product_tmpl_id" invisible="1"/> - <field name="product_variant_count" invisible="1"/> - <field name="product_id" widget="selection" - domain="[('product_tmpl_id', '=', product_tmpl_id)]" - attrs="{'readonly': [('product_variant_count', '=', 1)]}" - readonly="context.get('default_product_id')"/> - <field name="location_id" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/> - <field name="new_quantity"/> - </group> - <footer> - <button name="change_product_qty" string="_Apply" type="object" class="btn-primary"/> - <button string="_Cancel" class="btn-secondary" special="cancel" /> - </footer> - </form> - </field> - </record> - </data> -</odoo> - diff --git a/addons/stock/wizard/stock_quantity_history.py b/addons/stock/wizard/stock_quantity_history.py index afd83037190..5d09e55e0a9 100644 --- a/addons/stock/wizard/stock_quantity_history.py +++ b/addons/stock/wizard/stock_quantity_history.py @@ -33,6 +33,25 @@ class StockQuantityHistory(models.TransientModel): } return action else: - self.env['stock.quant']._merge_quants() - self.env['stock.quant']._unlink_zero_quants() - return self.env.ref('stock.quantsact').read()[0] + self = self.with_context(search_default_internal_loc=1) + if self.user_has_groups('stock.group_production_lot,stock.group_stock_multi_locations'): + # fixme: erase the following condition when it'll be possible to create a new record + # from a empty grouped editable list without go through the form view. + if self.env['stock.quant'].search_count([ + ('company_id', '=', self.env.user.company_id.id), + ('location_id.usage', 'in', ['internal', 'transit']) + ]) > 0: + self = self.with_context( + search_default_productgroup=1, + search_default_locationgroup=1 + ) + if not self.user_has_groups('stock.group_stock_multi_locations'): + company_user = self.env.user.company_id + warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_user.id)], limit=1) + if warehouse: + self = self.with_context(default_location_id=warehouse.lot_stock_id.id) + + # If user have rights to write on quant, we set quants in inventory mode. + if self.user_has_groups('stock.group_stock_manager'): + self = self.with_context(inventory_mode=True) + return self.env['stock.quant']._get_quants_action(extend=True) diff --git a/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py b/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py index 9ae58e320f0..c37c0e538aa 100644 --- a/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py +++ b/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py @@ -44,20 +44,18 @@ class TestWebsiteSaleStockProductWarehouse(TestWebsiteSaleProductAttributeValueC }) # Update quantity of Product A in Warehouse 1 - inventory_wizard = self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': product_1.id, - 'new_quantity': 10.0, + 'inventory_quantity': 10.0, 'location_id': warehouse_1.lot_stock_id.id, }) - inventory_wizard.change_product_qty() # Update quantity of Product B in Warehouse 2 - inventory_wizard = self.env['stock.change.product.qty'].create({ + self.env['stock.quant'].with_context(inventory_mode=True).create({ 'product_id': product_2.id, - 'new_quantity': 10.0, + 'inventory_quantity': 10.0, 'location_id': warehouse_2.lot_stock_id.id, }) - inventory_wizard.change_product_qty() # Get current website and set warehouse_id of Warehouse 1 current_website = self.env['website'].get_current_website() |
