summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/mrp/tests/test_order.py12
-rw-r--r--addons/mrp/tests/test_procurement.py12
-rw-r--r--addons/mrp/tests/test_workorder_operation.py6
-rw-r--r--addons/stock/__manifest__.py1
-rw-r--r--addons/stock/models/product.py62
-rw-r--r--addons/stock/models/stock_production_lot.py8
-rw-r--r--addons/stock/models/stock_quant.py218
-rw-r--r--addons/stock/tests/__init__.py1
-rw-r--r--addons/stock/tests/test_quant_inventory_mode.py215
-rw-r--r--addons/stock/tests/test_warehouse.py31
-rw-r--r--addons/stock/views/product_views.xml6
-rw-r--r--addons/stock/views/stock_production_lot_views.xml6
-rw-r--r--addons/stock/views/stock_quant_views.xml51
-rw-r--r--addons/stock/wizard/__init__.py1
-rw-r--r--addons/stock/wizard/stock_change_product_qty.py95
-rw-r--r--addons/stock/wizard/stock_change_product_qty_views.xml28
-rw-r--r--addons/stock/wizard/stock_quantity_history.py25
-rw-r--r--addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py10
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 &lt; 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 &lt; 0">
+ <tree create="0" delete="0" decoration-danger="quantity &lt; 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()