diff --git a/addons/stock/models/__init__.py b/addons/stock/models/__init__.py
index 214903e9f7d..03ac4fdf92a 100644
--- a/addons/stock/models/__init__.py
+++ b/addons/stock/models/__init__.py
@@ -6,7 +6,6 @@ from . import product_strategy
from . import res_company
from . import res_partner
from . import res_config_settings
-from . import stock_inventory
from . import stock_location
from . import stock_move
from . import stock_move_line
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index 0aa7c82c97b..5b3a424bf5d 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -849,7 +849,7 @@ class ProductTemplate(models.Model):
'stock.group_stock_multi_locations',
'stock.group_production_lot',
'stock.group_tracking_owner',
- 'product.group_stock_packaging'
+ 'product.group_tracking_lot'
]
if (self.env.user.user_has_groups(','.join(advanced_option_groups))):
return self.action_open_quants()
diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py
index 93a960edbf3..8ece71f4056 100644
--- a/addons/stock/models/res_company.py
+++ b/addons/stock/models/res_company.py
@@ -22,6 +22,26 @@ class Company(models.Model):
domain="[('model', '=', 'stock.picking')]",
default=_default_confirmation_mail_template,
help="Email sent to the customer once the order is done.")
+ annual_inventory_month = fields.Selection([
+ ('1', 'January'),
+ ('2', 'February'),
+ ('3', 'March'),
+ ('4', 'April'),
+ ('5', 'May'),
+ ('6', 'June'),
+ ('7', 'July'),
+ ('8', 'August'),
+ ('9', 'September'),
+ ('10', 'October'),
+ ('11', 'November'),
+ ('12', 'December'),
+ ], string='Annual Inventory Month',
+ default='12',
+ help="Annual inventory month for products not in a location with a cyclic inventory date. Set to no month if no automatic annual inventory.")
+ annual_inventory_day = fields.Integer(
+ string='Day of the month', default=31,
+ help="""Day of the month when the annual inventory should occur. If zero or negative, then the first day of the month will be selected instead.
+ If greater than the last day of a month, then the last day of the month will be selected instead.""")
def _create_transit_location(self):
'''Create a transit location with company_id being the given company_id. This is needed
@@ -77,7 +97,6 @@ class Company(models.Model):
company.id,
)
-
def _create_scrap_location(self):
parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
for company in self:
diff --git a/addons/stock/models/res_config_settings.py b/addons/stock/models/res_config_settings.py
index 014fe3875eb..11971f4f0b2 100644
--- a/addons/stock/models/res_config_settings.py
+++ b/addons/stock/models/res_config_settings.py
@@ -39,7 +39,10 @@ class ResConfigSettings(models.TransientModel):
module_quality_control_worksheet = fields.Boolean("Quality Worksheet")
group_stock_multi_locations = fields.Boolean('Storage Locations', implied_group='stock.group_stock_multi_locations',
help="Store products in specific locations of your warehouse (e.g. bins, racks) and to track inventory accordingly.")
- group_stock_storage_categories = fields.Boolean('Storage Categories', implied_group='stock.group_stock_storage_categories')
+ group_stock_storage_categories = fields.Boolean(
+ 'Storage Categories', implied_group='stock.group_stock_storage_categories')
+ annual_inventory_month = fields.Selection(related='company_id.annual_inventory_month', readonly=False)
+ annual_inventory_day = fields.Integer(related='company_id.annual_inventory_day', readonly=False)
@api.onchange('group_stock_multi_locations')
def _onchange_group_stock_multi_locations(self):
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
deleted file mode 100644
index 0081c357c20..00000000000
--- a/addons/stock/models/stock_inventory.py
+++ /dev/null
@@ -1,767 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from collections import defaultdict
-
-from odoo import _, api, fields, models
-from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
-from odoo.exceptions import UserError, ValidationError
-from odoo.osv import expression
-from odoo.tools import float_compare, float_is_zero
-
-
-class Inventory(models.Model):
- _name = "stock.inventory"
- _description = "Inventory"
- _order = "date desc, id desc"
- _inherit = ['mail.thread', 'mail.activity.mixin']
-
- name = fields.Char(
- 'Inventory Reference', default="Inventory",
- readonly=True, required=True,
- states={'draft': [('readonly', False)]})
- date = fields.Datetime(
- 'Inventory Date',
- readonly=True, required=True,
- default=fields.Datetime.now,
- help="If the inventory adjustment is not validated, date at which the theoritical quantities have been checked.\n"
- "If the inventory adjustment is validated, date at which the inventory adjustment has been validated.")
- line_ids = fields.One2many(
- 'stock.inventory.line', 'inventory_id', string='Inventories',
- copy=False, readonly=False,
- states={'done': [('readonly', True)]})
- move_ids = fields.One2many(
- 'stock.move', 'inventory_id', string='Created Moves',
- states={'done': [('readonly', True)]})
- state = fields.Selection(string='Status', selection=[
- ('draft', 'Draft'),
- ('cancel', 'Cancelled'),
- ('confirm', 'In Progress'),
- ('done', 'Validated')],
- copy=False, index=True, readonly=True, tracking=True,
- default='draft')
- company_id = fields.Many2one(
- 'res.company', 'Company',
- readonly=True, index=True, required=True,
- states={'draft': [('readonly', False)]},
- default=lambda self: self.env.company)
- location_ids = fields.Many2many(
- 'stock.location', string='Locations',
- readonly=True, check_company=True,
- states={'draft': [('readonly', False)]},
- domain="[('company_id', '=', company_id), ('usage', 'in', ['internal', 'transit'])]")
- product_ids = fields.Many2many(
- 'product.product', string='Products', check_company=True,
- domain="[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", readonly=True,
- states={'draft': [('readonly', False)]},
- help="Specify Products to focus your inventory on particular Products.")
- start_empty = fields.Boolean('Empty Inventory',
- help="Allows to start with an empty inventory.")
- prefill_counted_quantity = fields.Selection(string='Counted Quantities',
- help="Allows to start with a pre-filled counted quantity for each lines or "
- "with all counted quantities set to zero.", default='counted',
- selection=[('counted', 'Default to stock on hand'), ('zero', 'Default to zero')])
- exhausted = fields.Boolean(
- 'Include Exhausted Products', readonly=True,
- states={'draft': [('readonly', False)]},
- help="Include also products with quantity of 0")
- is_conflict_inventory = fields.Boolean(string="Is Auto-generated From Conflict", readonly=True,
- help="Technical flag to indicate this inventory was auto-generated due to a conflicting inventory. This allows us to auto-add/remove products when inventory is still in draft.")
- lot_ids = fields.Many2many('stock.production.lot', string='Duplicate Serial Numbers', readonly=True,
- help="Technical field to support auto-generated conflicting inventory from a duplicated SN. This value is expected to never be viewed/edited except by conflicting inventory checks.")
-
- @api.onchange('company_id')
- def _onchange_company_id(self):
- # If the multilocation group is not active, default the location to the one of the main
- # warehouse.
- if not self.user_has_groups('stock.group_stock_multi_locations'):
- warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
- if warehouse:
- self.location_ids = warehouse.lot_stock_id
-
- def copy_data(self, default=None):
- name = _("%s (copy)") % (self.name)
- default = dict(default or {}, name=name)
- return super(Inventory, self).copy_data(default)
-
- @api.ondelete(at_uninstall=False)
- def _unlink_if_draft_or_cancel(self):
- for inventory in self:
- if inventory.state not in ('draft', 'cancel'):
- raise UserError(_('You can only delete a draft inventory adjustment. If the inventory adjustment is not done, you can cancel it.'))
-
- def action_validate(self):
- if not self.exists():
- return
- self.ensure_one()
- if not self.user_has_groups('stock.group_stock_manager'):
- raise UserError(_("Only a stock manager can validate an inventory adjustment."))
- if self.state != 'confirm':
- raise UserError(_(
- "You can't validate the inventory '%s', maybe this inventory "
- "has been already validated or isn't ready.", self.name))
- inventory_lines = self.line_ids.filtered(lambda l: l.product_id.tracking in ['lot', 'serial'] and not l.prod_lot_id and l.theoretical_qty != l.product_qty)
- lines = self.line_ids.filtered(lambda l: float_compare(l.product_qty, 1, precision_rounding=l.product_uom_id.rounding) > 0 and l.product_id.tracking == 'serial' and l.prod_lot_id)
- if inventory_lines and not lines:
- wiz_lines = [(0, 0, {'product_id': product.id, 'tracking': product.tracking}) for product in inventory_lines.mapped('product_id')]
- wiz = self.env['stock.track.confirmation'].create({'inventory_id': self.id, 'tracking_line_ids': wiz_lines})
- return {
- 'name': _('Tracked Products in Inventory Adjustment'),
- 'type': 'ir.actions.act_window',
- 'view_mode': 'form',
- 'views': [(False, 'form')],
- 'res_model': 'stock.track.confirmation',
- 'target': 'new',
- 'res_id': wiz.id,
- }
- self._action_done()
- self.line_ids._check_company()
- self._check_company()
- return True
-
- def _action_done(self):
- negative = next((line for line in self.mapped('line_ids') if line.product_qty < 0 and line.product_qty != line.theoretical_qty), False)
- if negative:
- raise UserError(_(
- 'You cannot set a negative product quantity in an inventory line:\n\t%s - qty: %s',
- negative.product_id.display_name,
- negative.product_qty
- ))
- self.action_check()
- self.write({'state': 'done', 'date': fields.Datetime.now()})
- self.post_inventory()
- if self.user_has_groups('stock.group_stock_multi_locations') and not self.product_ids and self.location_ids:
- locations = self.env['stock.location'].with_context(active_test=False).search([('id', 'child_of', self.location_ids.ids), ('usage', 'in', ['internal', 'transit'])])
- locations.last_inventory_date = fields.Datetime.now()
- return True
-
- def post_inventory(self):
- # The inventory is posted as a single step which means quants cannot be moved from an internal location to another using an inventory
- # as they will be moved to inventory loss, and other quants will be created to the encoded quant location. This is a normal behavior
- # as quants cannot be reuse from inventory location (users can still manually move the products before/after the inventory if they want).
- self.mapped('move_ids').filtered(lambda move: move.state != 'done')._action_done()
- return True
-
- def action_check(self):
- """ Checks the inventory and computes the stock move to do """
- # tde todo: clean after _generate_moves
- for inventory in self.filtered(lambda x: x.state not in ('done','cancel')):
- # first remove the existing stock moves linked to this inventory
- inventory.with_context(prefetch_fields=False).mapped('move_ids').unlink()
- inventory.line_ids._generate_moves()
-
- def action_cancel_draft(self):
- self.mapped('move_ids')._action_cancel()
- self.line_ids.unlink()
- self.write({'state': 'draft'})
-
- def action_start(self):
- self.ensure_one()
- self._action_start()
- self._check_company()
- return self.action_open_inventory_lines()
-
- def _action_start(self):
- """ Confirms the Inventory Adjustment and generates its inventory lines
- if its state is draft and don't have already inventory lines (can happen
- with demo data or tests).
- """
- for inventory in self:
- if inventory.state != 'draft':
- continue
- vals = {
- 'state': 'confirm',
- 'date': fields.Datetime.now()
- }
- if not inventory.line_ids and not inventory.start_empty:
- self.env['stock.inventory.line'].create(inventory._get_inventory_lines_values())
- inventory.write(vals)
-
- def action_open_inventory_lines(self):
- self.ensure_one()
- action = {
- 'type': 'ir.actions.act_window',
- 'view_mode': 'tree',
- 'name': _('Inventory Lines'),
- 'res_model': 'stock.inventory.line',
- }
- context = {
- 'default_is_editable': True,
- 'default_inventory_id': self.id,
- 'default_company_id': self.company_id.id,
- }
- # Define domains and context
- domain = [
- ('inventory_id', '=', self.id),
- ('location_id.usage', 'in', ['internal', 'transit'])
- ]
- if self.location_ids:
- context['default_location_id'] = self.location_ids[0].id
- if len(self.location_ids) == 1:
- if not self.location_ids[0].child_ids:
- context['readonly_location_id'] = True
-
- if self.product_ids:
- # no_create on product_id field
- action['view_id'] = self.env.ref('stock.stock_inventory_line_tree_no_product_create').id
- if len(self.product_ids) == 1:
- context['default_product_id'] = self.product_ids[0].id
- else:
- # no product_ids => we're allowed to create new products in tree
- action['view_id'] = self.env.ref('stock.stock_inventory_line_tree').id
-
- action['context'] = context
- action['domain'] = domain
- return action
-
- def action_view_related_move_lines(self):
- self.ensure_one()
- domain = [('move_id', 'in', self.move_ids.ids)]
- action = {
- 'name': _('Product Moves'),
- 'type': 'ir.actions.act_window',
- 'res_model': 'stock.move.line',
- 'view_type': 'list',
- 'view_mode': 'list,form',
- 'domain': domain,
- }
- return action
-
- def action_print(self):
- return self.env.ref('stock.action_report_inventory').report_action(self)
-
- def _get_quantities(self):
- """Return quantities group by product_id, location_id, lot_id, package_id and owner_id
-
- :return: a dict with keys as tuple of group by and quantity as value
- :rtype: dict
- """
- self.ensure_one()
- if self.location_ids:
- domain_loc = [('id', 'child_of', self.location_ids.ids)]
- else:
- domain_loc = [('company_id', '=', self.company_id.id), ('usage', 'in', ['internal', 'transit'])]
- locations_ids = [l['id'] for l in self.env['stock.location'].search_read(domain_loc, ['id'])]
-
- domain = [('company_id', '=', self.company_id.id),
- ('location_id', 'in', locations_ids)]
- if self.prefill_counted_quantity == 'zero':
- domain.append(('product_id.active', '=', True))
- if self.lot_ids:
- domain = expression.AND([domain, [('lot_id', 'in', self.lot_ids.ids)]])
- if self.is_conflict_inventory and not self.lot_ids:
- domain = expression.AND([domain, [('quantity', '<', 0)]])
- else:
- domain.append(('quantity', '!=', 0))
- if self.product_ids:
- domain = expression.AND([domain, [('product_id', 'in', self.product_ids.ids)]])
-
- fields = ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id', 'quantity:sum']
- group_by = ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id']
-
- quants = self.env['stock.quant'].read_group(domain, fields, group_by, lazy=False)
- return {(
- quant['product_id'] and quant['product_id'][0] or False,
- quant['location_id'] and quant['location_id'][0] or False,
- quant['lot_id'] and quant['lot_id'][0] or False,
- quant['package_id'] and quant['package_id'][0] or False,
- quant['owner_id'] and quant['owner_id'][0] or False):
- quant['quantity'] for quant in quants
- }
-
- def _get_exhausted_inventory_lines_vals(self, non_exhausted_set):
- """Return the values of the inventory lines to create if the user
- wants to include exhausted products. Exhausted products are products
- without quantities or quantity equal to 0.
-
- :param non_exhausted_set: set of tuple (product_id, location_id) of non exhausted product-location
- :return: a list containing the `stock.inventory.line` values to create
- :rtype: list
- """
- self.ensure_one()
- if self.product_ids:
- product_ids = self.product_ids.ids
- else:
- product_ids = self.env['product.product'].search_read([
- '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
- ('type', '=', 'product'),
- ('active', '=', True)], ['id'])
- product_ids = [p['id'] for p in product_ids]
-
- if self.location_ids:
- location_ids = self.location_ids.ids
- else:
- location_ids = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)]).lot_stock_id.ids
-
- vals = []
- for product_id in product_ids:
- for location_id in location_ids:
- if ((product_id, location_id) not in non_exhausted_set):
- vals.append({
- 'inventory_id': self.id,
- 'product_id': product_id,
- 'location_id': location_id,
- 'theoretical_qty': 0
- })
- return vals
-
- def _get_inventory_lines_values(self):
- """Return the values of the inventory lines to create for this inventory.
-
- :return: a list containing the `stock.inventory.line` values to create
- :rtype: list
- """
- self.ensure_one()
- quants_groups = self._get_quantities()
- vals = []
- for (product_id, location_id, lot_id, package_id, owner_id), quantity in quants_groups.items():
- line_values = {
- 'inventory_id': self.id,
- 'product_qty': 0 if self.prefill_counted_quantity == "zero" else quantity,
- 'theoretical_qty': quantity,
- 'prod_lot_id': lot_id,
- 'partner_id': owner_id,
- 'product_id': product_id,
- 'location_id': location_id,
- 'package_id': package_id
- }
- line_values['product_uom_id'] = self.env['product.product'].browse(product_id).uom_id.id
- vals.append(line_values)
- if self.exhausted:
- vals += self._get_exhausted_inventory_lines_vals({(l['product_id'], l['location_id']) for l in vals})
- return vals
-
- @api.model
- def _run_inventory_tasks(self, company_id=False):
- """ Run conflict inventory tasks and generate/clean up cyclic inventories for locations as follows:
- - [to create]: - The location has a cyclic count set (i.e. inventory every XX days) and has a next_inventory_date of today or earlier
- - The location is not a descendant of another location that is having a cyclic inventory created
- - The location doesn't already have an in progress inventory specific to (i.e. only for) it
- - [to delete]: - Existing draft cyclic inventory that was created before today (i.e. still draft) [new one created afterwards]
- - Existing draft location inventory that is a descendant of a location that has a next_inventory_date of today or earlier
- """
- # if cron triggered => apply to all companies
- company_domain = [('company_id', '!=', False)]
- if company_id:
- # if manually triggered => only apply to user's companies
- company_domain = [('company_id', '=', company_id)]
-
- # cyclic location inventories
- domain = expression.AND([[('next_inventory_date', '<=', fields.Date.today())], company_domain])
- locations = self.env['stock.location'].search(domain)
- if locations:
- # ignore any locations that are children of other locations that should have an inventory done
- locations = locations.filtered(lambda l: not any(int(location_id) in locations.ids for location_id in l.parent_path.split('/')[:-2]))
- existing_loc_invs = self.search([('state', 'in', ['draft', 'confirm']), ('product_ids', '=', False), ('location_ids', '!=', False)])
- invs_to_unlink = self.env['stock.inventory']
- existing_cyclic_inv = self.env['stock.inventory']
- for inventory in existing_loc_invs:
- # assume inventories with more than 1 location were manually created and should be untouched
- if len(inventory.location_ids) == 1:
- if inventory.state == 'draft' \
- and (any(int(location_id) in locations.ids for location_id in inventory.location_ids[0].parent_path.split('/')[:-2])
- or (inventory.location_ids[0] in locations and inventory.create_date < fields.Datetime.today())):
- invs_to_unlink |= inventory
- elif inventory.location_ids[0] in locations:
- existing_cyclic_inv |= inventory
- invs_to_unlink.unlink()
- locations -= existing_cyclic_inv.mapped('location_ids')
- location_vals = []
- for location in locations:
- location_vals.append({'name': "Reoccuring Inventory for: " + str(location.name),
- 'company_id': location.company_id.id,
- 'location_ids': location})
- self.create(location_vals)
- # conflict inventories handled in separate function so inventories can also be updated when user opens inventory menuitem
- self._run_conflict_inventory_tasks(company_id=company_id)
-
- @api.model
- def _run_conflict_inventory_tasks(self, company_id=False):
- """ Updates/creates/deletes conflict inventories. Conflict inventories include:
- - negative quantities values, created per warehouse
- Note that an in progress inventory will prevent an update/unlink when its
- corresponding values are out of date.
- """
- company_domain = [('company_id', '!=', False)]
- if company_id:
- # if manually triggered => only apply to user's companies
- company_domain = [('company_id', '=', company_id)]
- # negative quantity check
- inventory_vals_to_create = []
- updated_invs = self.env['stock.inventory']
- existing_conflict_invs = self.search(expression.AND([
- [
- ('state', 'in', ['draft', 'confirm']),
- ('is_conflict_inventory', '=', True)
- ], company_domain]))
- neg_quants = self.env['stock.quant'].search(expression.AND([
- [
- ('quantity', '<', 0.0),
- ('location_id.usage', 'in', ['internal', 'transit'])
- ], company_domain]))
- company_ids = neg_quants.mapped('company_id')
- warehouse_ids = self.env['stock.warehouse'].search([('company_id', 'in', company_ids.ids)])
- warehouse_locations = self.env['stock.location'].search([('id', 'child_of', warehouse_ids.mapped('view_location_id').ids)])
- company_to_warehouses = defaultdict(lambda: self.env['stock.warehouse'])
- warehouse_to_locations = defaultdict(lambda: self.env['stock.location'])
- for warehouse in warehouse_ids:
- company_to_warehouses[warehouse.company_id] |= warehouse
- warehouse_to_locations[warehouse] = warehouse_locations.filtered(lambda l: any(int(location_id) == warehouse.view_location_id.id for location_id in l.parent_path.split('/')[:-2]))
- for company_id in company_ids:
- # separate auto-generated inventories by warehouse
- for warehouse in company_to_warehouses[company_id]:
- # avoid conflicting inventories! Wait until the next time this is run after the previous neg qty inventory is completed
- if existing_conflict_invs.filtered(lambda i: i.state == 'confirm' and i.company_id == company_id and i.location_ids & warehouse_to_locations[warehouse]):
- continue
- warehouse_quants = neg_quants.filtered(lambda q: q.location_id in warehouse_to_locations[warehouse])
- if warehouse_quants:
- draft_inv = existing_conflict_invs.filtered(lambda i: i.state == 'draft' and i.company_id == company_id and i.location_ids & warehouse_to_locations[warehouse])
- if draft_inv:
- # only write in in first draft in case there are duplicates due to function being called while its already
- draft_inv[0].write({'product_ids': warehouse_quants.mapped('product_id')})
- updated_invs |= draft_inv[0]
- else:
- inventory_vals_to_create.append({
- 'name': "Negative Quantity Inventory: " + warehouse.name,
- 'company_id': company_id.id,
- 'product_ids': warehouse_quants.mapped('product_id'),
- 'is_conflict_inventory': True,
- 'location_ids': warehouse.view_location_id.child_ids
- })
-
- # conflicting SN check
- domain = expression.AND([[('location_id.usage', 'in', ['internal', 'transit']),
- ('lot_id', '!=', False),
- ('product_id.tracking', '=', 'serial'),
- ('quantity', '!=', 0.0)],
- company_domain])
- quants = self.env['stock.quant'].read_group(domain, ['lot_id', 'company_id', 'product_id'], ['lot_id', 'company_id', 'product_id'], lazy=False)
- company_to_sn_conflicts = defaultdict(lambda: ([], []))
- for quant in quants:
- if quant['__count'] > 1:
- company_to_sn_conflicts[quant['company_id'][0]][0].append(quant['lot_id'][0])
- company_to_sn_conflicts[quant['company_id'][0]][1].append(quant['product_id'][0])
-
- for company_id, (lot_ids, product_ids) in company_to_sn_conflicts.items():
- # avoid conflicting inventories! Wait until the next time this is run after the previous conflicting SN inventory is completed
- if existing_conflict_invs.filtered(lambda i: i.state == 'confirm' and i.lot_ids and i.company_id.id == company_id):
- continue
- draft_inv = existing_conflict_invs.filtered(lambda i: i.state == 'draft' and i.lot_ids and i.company_id.id == company_id)
- if draft_inv:
- draft_inv.write({'lot_ids': lot_ids,
- 'product_ids': product_ids})
- updated_invs |= draft_inv
- else:
- inventory_vals_to_create.append({
- 'name': "Duplicate SN Inventory",
- 'company_id': company_id,
- 'lot_ids': lot_ids,
- 'product_ids': product_ids,
- 'is_conflict_inventory': True
- })
- self.create(inventory_vals_to_create)
- # remove all obsolete conflict inventories
- (existing_conflict_invs.filtered(lambda i: i.state == 'draft') - updated_invs).unlink()
-
- @api.model
- def action_open_inventory_view(self):
- self._run_conflict_inventory_tasks()
- return self.env["ir.actions.actions"]._for_xml_id("stock.action_inventory_form")
-
-
-class InventoryLine(models.Model):
- _name = "stock.inventory.line"
- _description = "Inventory Line"
- _order = "product_id, inventory_id, location_id, prod_lot_id"
-
- @api.model
- def _domain_location_id(self):
- if self.env.context.get('active_model') == 'stock.inventory':
- inventory = self.env['stock.inventory'].browse(self.env.context.get('active_id'))
- if inventory.exists() and inventory.location_ids:
- return "[('company_id', '=', company_id), ('usage', 'in', ['internal', 'transit']), ('id', 'child_of', %s)]" % inventory.location_ids.ids
- return "[('company_id', '=', company_id), ('usage', 'in', ['internal', 'transit'])]"
-
- @api.model
- def _domain_product_id(self):
- if self.env.context.get('active_model') == 'stock.inventory':
- inventory = self.env['stock.inventory'].browse(self.env.context.get('active_id'))
- if inventory.exists() and len(inventory.product_ids) > 1:
- return "[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id), ('id', 'in', %s)]" % inventory.product_ids.ids
- return "[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]"
-
- is_editable = fields.Boolean(help="Technical field to restrict editing.")
- inventory_id = fields.Many2one(
- 'stock.inventory', 'Inventory', check_company=True,
- index=True, ondelete='cascade')
- partner_id = fields.Many2one('res.partner', 'Owner', check_company=True)
- product_id = fields.Many2one(
- 'product.product', 'Product', check_company=True,
- domain=lambda self: self._domain_product_id(),
- index=True, required=True)
- product_uom_id = fields.Many2one(
- 'uom.uom', 'Product Unit of Measure',
- required=True, readonly=True)
- product_qty = fields.Float(
- 'Counted Quantity',
- readonly=True, states={'confirm': [('readonly', False)]},
- digits='Product Unit of Measure', default=0)
- categ_id = fields.Many2one(related='product_id.categ_id', store=True)
- location_id = fields.Many2one(
- 'stock.location', 'Location', check_company=True,
- domain=lambda self: self._domain_location_id(),
- index=True, required=True)
- package_id = fields.Many2one(
- 'stock.quant.package', 'Pack', index=True, check_company=True,
- domain="[('location_id', '=', location_id)]",
- )
- prod_lot_id = fields.Many2one(
- 'stock.production.lot', 'Lot/Serial Number', check_company=True,
- domain="[('product_id','=',product_id), ('company_id', '=', company_id)]")
- company_id = fields.Many2one(
- 'res.company', 'Company', related='inventory_id.company_id',
- index=True, readonly=True, store=True)
- state = fields.Selection(string='Status', related='inventory_id.state')
- theoretical_qty = fields.Float(
- 'Theoretical Quantity',
- digits='Product Unit of Measure', readonly=True)
- difference_qty = fields.Float('Difference', compute='_compute_difference',
- help="Indicates the gap between the product's theoretical quantity and its newest quantity.",
- readonly=True, digits='Product Unit of Measure', search="_search_difference_qty")
- inventory_date = fields.Datetime('Inventory Date', readonly=True,
- default=fields.Datetime.now,
- help="Last date at which the On Hand Quantity has been computed.")
- outdated = fields.Boolean(string='Quantity outdated',
- compute='_compute_outdated', search='_search_outdated')
- product_tracking = fields.Selection(string='Tracking', related='product_id.tracking', readonly=True)
-
- @api.depends('product_qty', 'theoretical_qty')
- def _compute_difference(self):
- for line in self:
- line.difference_qty = line.product_qty - line.theoretical_qty
-
- @api.depends('inventory_date', 'product_id.stock_move_ids', 'theoretical_qty', 'product_uom_id.rounding')
- def _compute_outdated(self):
- quants_by_inventory = {inventory: inventory._get_quantities() for inventory in self.inventory_id}
- for line in self:
- quants = quants_by_inventory[line.inventory_id]
- if line.state == 'done' or not line.id:
- line.outdated = False
- continue
- qty = quants.get((
- line.product_id.id,
- line.location_id.id,
- line.prod_lot_id.id,
- line.package_id.id,
- line.partner_id.id), 0
- )
- if float_compare(qty, line.theoretical_qty, precision_rounding=line.product_uom_id.rounding) != 0:
- line.outdated = True
- else:
- line.outdated = False
-
- @api.onchange('product_id', 'location_id', 'product_uom_id', 'prod_lot_id', 'partner_id', 'package_id')
- def _onchange_quantity_context(self):
- if self.product_id:
- self.product_uom_id = self.product_id.uom_id
- if self.product_id and self.location_id and self.product_id.uom_id.category_id == self.product_uom_id.category_id: # TDE FIXME: last part added because crash
- theoretical_qty = self.product_id.get_theoretical_quantity(
- self.product_id.id,
- self.location_id.id,
- lot_id=self.prod_lot_id.id,
- package_id=self.package_id.id,
- owner_id=self.partner_id.id,
- to_uom=self.product_uom_id.id,
- )
- else:
- theoretical_qty = 0
- # Sanity check on the lot.
- if self.prod_lot_id:
- if self.product_id.tracking == 'none' or self.product_id != self.prod_lot_id.product_id:
- self.prod_lot_id = False
-
- if self.prod_lot_id and self.product_id.tracking == 'serial':
- # We force `product_qty` to 1 for SN tracked product because it's
- # the only relevant value aside 0 for this kind of product.
- self.product_qty = 1
- elif self.product_id and float_compare(self.product_qty, self.theoretical_qty, precision_rounding=self.product_uom_id.rounding) == 0:
- # We update `product_qty` only if it equals to `theoretical_qty` to
- # avoid to reset quantity when user manually set it.
- self.product_qty = theoretical_qty
- self.theoretical_qty = theoretical_qty
-
- @api.onchange('product_qty', 'prod_lot_id')
- def _onchange_serial_number(self):
- if self.prod_lot_id and self.product_id.tracking == 'serial' and not float_is_zero(self.product_qty, self.product_id.uom_id.rounding):
- dupe_sn_lines = self.env['stock.inventory.line']
- message = _('Is this expected? For example this can happen if pick-pack-ship was not completed in the right order. In this case the issue will be solved '
- 'automatically once all steps are completed. Otherwise, you can adjust quantities in these locations.')
- # first check current inventory for non-zero dupe SNs
- if self.inventory_id:
- dupe_sn_lines |= self.inventory_id.line_ids.filtered(
- lambda l: l.product_id == self.product_id
- and l.prod_lot_id == self.prod_lot_id
- and l.location_id.id is not self.location_id.id
- and not float_is_zero(l.product_qty, l.product_id.uom_id.rounding))
- if len(dupe_sn_lines) > 0:
- message = _('There is already one or more inventory adjustment line(s) counting the Serial Number (%s) at the location(s): %s.\n\n',
- self.prod_lot_id.name, ', '.join(dupe_sn_lines.location_id.mapped('display_name'))) + message
- return {'warning': {'title': _('Warning'), 'message': message}}
- # then check locations not in inventory for dupe SNs
- if self.inventory_id and self.inventory_id.location_ids:
- quants = self.env['stock.quant'].search([('product_id', '=', self.product_id.id),
- ('lot_id', '=', self.prod_lot_id.id),
- ('quantity', '!=', 0),
- ('location_id', 'not in', self.inventory_id.location_ids.ids),
- '|', ('location_id.usage', '=', 'customer'),
- '&', ('company_id', '=', self.company_id.id),
- ('location_id.usage', 'in', ('internal', 'transit'))])
- if quants:
- message = _('The Serial Number (%s) is already used in these location(s): %s.\n\n',
- self.prod_lot_id.name, ', '.join(quants.location_id.mapped('display_name'))) + message
- return {'warning': {'title': _('Warning'), 'message': message}}
-
- @api.model_create_multi
- def create(self, vals_list):
- """ Override to handle the case we create inventory line without
- `theoretical_qty` because this field is usually computed, but in some
- case (typicaly in tests), we create inventory line without trigger the
- onchange, so in this case, we set `theoretical_qty` depending of the
- product's theoretical quantity.
- Handles the same problem with `product_uom_id` as this field is normally
- set in an onchange of `product_id`.
- Finally, this override checks we don't try to create a duplicated line.
- """
- for values in vals_list:
- if 'theoretical_qty' not in values:
- theoretical_qty = self.env['product.product'].get_theoretical_quantity(
- values['product_id'],
- values['location_id'],
- lot_id=values.get('prod_lot_id'),
- package_id=values.get('package_id'),
- owner_id=values.get('partner_id'),
- to_uom=values.get('product_uom_id'),
- )
- values['theoretical_qty'] = theoretical_qty
- if 'product_id' in values and 'product_uom_id' not in values:
- values['product_uom_id'] = self.env['product.product'].browse(values['product_id']).uom_id.id
- res = super(InventoryLine, self).create(vals_list)
- res._check_no_duplicate_line()
- return res
-
- def write(self, vals):
- res = super(InventoryLine, self).write(vals)
- self._check_no_duplicate_line()
- return res
-
- def _check_no_duplicate_line(self):
- for line in self:
- domain = [
- ('id', '!=', line.id),
- ('product_id', '=', line.product_id.id),
- ('location_id', '=', line.location_id.id),
- ('partner_id', '=', line.partner_id.id),
- ('package_id', '=', line.package_id.id),
- ('prod_lot_id', '=', line.prod_lot_id.id),
- ('inventory_id', '=', line.inventory_id.id)]
- existings = self.search_count(domain)
- if existings:
- raise UserError(_("There is already one inventory adjustment line for this product,"
- " you should rather modify this one instead of creating a new one."))
-
- @api.constrains('product_id')
- def _check_product_id(self):
- """ As no quants are created for consumable products, it should not be possible do adjust
- their quantity.
- """
- for line in self:
- if line.product_id.type != 'product':
- raise ValidationError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
-
- def _get_move_values(self, qty, location_id, location_dest_id, out):
- self.ensure_one()
- return {
- 'name': _('INV:') + (self.inventory_id.name or ''),
- 'product_id': self.product_id.id,
- 'product_uom': self.product_uom_id.id,
- 'product_uom_qty': qty,
- 'date': self.inventory_id.date,
- 'company_id': self.inventory_id.company_id.id,
- 'inventory_id': self.inventory_id.id,
- 'state': 'confirmed',
- 'restrict_partner_id': self.partner_id.id,
- 'location_id': location_id,
- 'location_dest_id': location_dest_id,
- 'move_line_ids': [(0, 0, {
- 'product_id': self.product_id.id,
- 'lot_id': self.prod_lot_id.id,
- 'product_uom_qty': 0, # bypass reservation here
- 'product_uom_id': self.product_uom_id.id,
- 'qty_done': qty,
- 'package_id': out and self.package_id.id or False,
- 'result_package_id': (not out) and self.package_id.id or False,
- 'location_id': location_id,
- 'location_dest_id': location_dest_id,
- 'owner_id': self.partner_id.id,
- })]
- }
-
- def _get_virtual_location(self):
- return self.product_id.with_company(self.company_id).property_stock_inventory
-
- def _generate_moves(self):
- vals_list = []
- for line in self:
- virtual_location = line._get_virtual_location()
- rounding = line.product_id.uom_id.rounding
- if float_is_zero(line.difference_qty, precision_rounding=rounding):
- continue
- if line.difference_qty > 0: # found more than expected
- vals = line._get_move_values(line.difference_qty, virtual_location.id, line.location_id.id, False)
- else:
- vals = line._get_move_values(abs(line.difference_qty), line.location_id.id, virtual_location.id, True)
- vals_list.append(vals)
- return self.env['stock.move'].create(vals_list)
-
- def action_refresh_quantity(self):
- filtered_lines = self.filtered(lambda l: l.state != 'done')
- for line in filtered_lines:
- if line.outdated:
- quants = self.env['stock.quant']._gather(line.product_id, line.location_id, lot_id=line.prod_lot_id, package_id=line.package_id, owner_id=line.partner_id, strict=True)
- if quants.exists():
- quantity = sum(quants.mapped('quantity'))
- if line.theoretical_qty != quantity:
- line.theoretical_qty = quantity
- else:
- line.theoretical_qty = 0
- line.inventory_date = fields.Datetime.now()
-
- def action_reset_product_qty(self):
- """ Write `product_qty` to zero on the selected records. """
- impacted_lines = self.env['stock.inventory.line']
- for line in self:
- if line.state == 'done':
- continue
- impacted_lines |= line
- impacted_lines.write({'product_qty': 0})
-
- def _search_difference_qty(self, operator, value):
- if operator == '=':
- result = True
- elif operator == '!=':
- result = False
- else:
- raise NotImplementedError()
- lines = self.search([('inventory_id', '=', self.env.context.get('default_inventory_id'))])
- line_ids = lines.filtered(lambda line: float_is_zero(line.difference_qty, line.product_id.uom_id.rounding) == result).ids
- return [('id', 'in', line_ids)]
-
- def _search_outdated(self, operator, value):
- if operator != '=':
- if operator == '!=' and isinstance(value, bool):
- value = not value
- else:
- raise NotImplementedError()
- lines = self.search([('inventory_id', '=', self.env.context.get('default_inventory_id'))])
- line_ids = lines.filtered(lambda line: line.outdated == value).ids
- return [('id', 'in', line_ids)]
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 54a890b7946..fd6eda2e23c 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import calendar
+
from collections import defaultdict, OrderedDict
from datetime import timedelta
@@ -63,8 +66,8 @@ class Location(models.Model):
putaway_rule_ids = fields.One2many('stock.putaway.rule', 'location_in_id', 'Putaway Rules')
barcode = fields.Char('Barcode', copy=False)
quant_ids = fields.One2many('stock.quant', 'location_id')
- cyclic_inventory_frequency = fields.Integer("Inventory Frequency (Days)", default=0, help=" When different than 0, inventory adjustments for products stored at this location will be created automatically at the defined frequency.")
- last_inventory_date = fields.Datetime("Last Effective Inventory", readonly=True, help="Date of the last inventory at this location.")
+ cyclic_inventory_frequency = fields.Integer("Inventory Frequency (Days)", default=0, help=" When different than 0, inventory count date for products stored at this location will be automatically set at the defined frequency.")
+ last_inventory_date = fields.Date("Last Effective Inventory", readonly=True, help="Date of the last inventory at this location.")
next_inventory_date = fields.Date("Next Expected Inventory", compute="_compute_next_inventory_date", store=True, help="Date for next planned inventory based on cyclic schedule.")
warehouse_view_ids = fields.One2many('stock.warehouse', 'view_location_id', readonly=True)
warehouse_id = fields.Many2one('stock.warehouse', compute='_compute_warehouse_id')
@@ -109,7 +112,7 @@ class Location(models.Model):
if location.company_id and location.usage in ['internal', 'transit'] and location.cyclic_inventory_frequency > 0:
try:
if location.last_inventory_date:
- days_until_next_inventory = location.cyclic_inventory_frequency - (fields.Date.today() - location.last_inventory_date.date()).days
+ days_until_next_inventory = location.cyclic_inventory_frequency - (fields.Date.today() - location.last_inventory_date).days
if days_until_next_inventory <= 0:
location.next_inventory_date = fields.Date.today() + timedelta(days=1)
else:
@@ -232,6 +235,35 @@ class Location(models.Model):
return putaway_rules._get_putaway_location(product, quantity, package, qty_by_location) or self
+ def _get_next_inventory_date(self):
+ """ Used to get the next inventory date for a quant located in this location. It is
+ based on:
+ 1. Does the location have a cyclic inventory set?
+ 2. If not 1, then is there an annual inventory date set (for its company)?
+ 3. If not 1 and 2, then quants have no next inventory date."""
+ if self.usage not in ['internal', 'transit']:
+ return False
+ next_inventory_date = False
+ if self.next_inventory_date:
+ next_inventory_date = self.next_inventory_date
+ elif self.company_id.annual_inventory_month:
+ today = fields.Date.today()
+ annual_inventory_month = int(self.company_id.annual_inventory_month)
+ # Manage 0 and negative annual_inventory_day
+ annual_inventory_day = max(self.company_id.annual_inventory_day, 1)
+ max_day = calendar.monthrange(today.year, annual_inventory_month)[1]
+ # Manage annual_inventory_day bigger than last_day
+ annual_inventory_day = min(annual_inventory_day, max_day)
+ next_inventory_date = today.replace(
+ month=annual_inventory_month, day=annual_inventory_day)
+ if next_inventory_date <= today:
+ # Manage leap year with the february
+ max_day = calendar.monthrange(today.year + 1, annual_inventory_month)[1]
+ annual_inventory_day = min(annual_inventory_day, max_day)
+ next_inventory_date = next_inventory_date.replace(
+ day=annual_inventory_day, year=today.year + 1)
+ return next_inventory_date
+
def should_bypass_reservation(self):
self.ensure_one()
return self.usage in ('supplier', 'customer', 'inventory', 'production') or self.scrap_location or (self.usage == 'transit' and not self.company_id)
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 577c64db47a..1fedcc7c1ba 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -130,7 +130,7 @@ class StockMove(models.Model):
help='If checked, when this move is cancelled, cancel the linked move too')
delay_alert_date = fields.Datetime('Delay Alert Date', help='Process at this date to be on time', compute="_compute_delay_alert_date", store=True)
picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type', compute='_compute_picking_type_id', store=True, check_company=True)
- inventory_id = fields.Many2one('stock.inventory', 'Inventory', check_company=True)
+ is_inventory = fields.Boolean('Inventory')
move_line_ids = fields.One2many('stock.move.line', 'move_id')
move_line_nosuggest_ids = fields.One2many('stock.move.line', 'move_id', domain=['|', ('product_qty', '=', 0.0), ('qty_done', '!=', 0.0)])
origin_returned_move_id = fields.Many2one(
@@ -1507,13 +1507,13 @@ class StockMove(models.Model):
# Cancel moves where necessary ; we should do it before creating the extra moves because
# this operation could trigger a merge of moves.
for move in moves:
- if move.quantity_done <= 0:
+ if move.quantity_done <= 0 and not move.is_inventory:
if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0 or cancel_backorder:
move._action_cancel()
# Create extra moves where necessary
for move in moves:
- if move.state == 'cancel' or move.quantity_done <= 0:
+ if move.state == 'cancel' or (move.quantity_done <= 0 and not move.is_inventory):
continue
moves_ids_todo |= move._create_extra_move().ids
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
index 89efc4582f8..dd7cbaf9ac1 100644
--- a/addons/stock/models/stock_move_line.py
+++ b/addons/stock/models/stock_move_line.py
@@ -62,6 +62,7 @@ class StockMoveLine(models.Model):
picking_type_entire_packs = fields.Boolean(related='picking_id.picking_type_id.show_entire_packs', readonly=True)
state = fields.Selection(related='move_id.state', store=True, related_sudo=False)
is_initial_demand_editable = fields.Boolean(related='move_id.is_initial_demand_editable')
+ is_inventory = fields.Boolean(related='move_id.is_inventory')
is_locked = fields.Boolean(related='move_id.is_locked', default=True, readonly=True)
consume_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'consume_line_id', 'produce_line_id', help="Technical link to see who consumed what. ")
produce_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'produce_line_id', 'consume_line_id', help="Technical link to see which line was produced with this. ")
@@ -511,7 +512,7 @@ class StockMoveLine(models.Model):
# checkboxes on the picking type, he's allowed to enter tracked
# products without a `lot_id`.
continue
- elif ml.move_id.inventory_id:
+ elif ml.is_inventory:
# If an inventory adjustment is linked, the user is allowed to enter
# tracked products without a `lot_id`.
continue
@@ -520,7 +521,7 @@ class StockMoveLine(models.Model):
ml_ids_tracked_without_lot.add(ml.id)
elif qty_done_float_compared < 0:
raise UserError(_('No negative quantities allowed'))
- else:
+ elif not ml.is_inventory:
ml_ids_to_delete.add(ml.id)
if ml_ids_tracked_without_lot:
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
index a3de8614b84..1f79c6ff7d1 100644
--- a/addons/stock/models/stock_quant.py
+++ b/addons/stock/models/stock_quant.py
@@ -8,7 +8,7 @@ from psycopg2 import Error, OperationalError
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, float_round
+from odoo.tools.float_utils import float_compare, float_is_zero
_logger = logging.getLogger(__name__)
@@ -80,9 +80,6 @@ class StockQuant(models.Model):
'Quantity',
help='Quantity of products in this quant, in the default unit of measure of the product',
readonly=True)
- 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,
@@ -96,39 +93,36 @@ class StockQuant(models.Model):
tracking = fields.Selection(related='product_id.tracking', readonly=True)
on_hand = fields.Boolean('On Hand', store=False, search='_search_on_hand')
+ # Inventory Fields
+ inventory_quantity = fields.Float(
+ 'Counted Quantity', digits='Product Unit of Measure', groups='stock.group_stock_manager',
+ help="The product's counted quantity.")
+ inventory_diff_quantity = fields.Float(
+ 'Difference', compute='_compute_inventory_diff_quantity', store=True,
+ help="Indicates the gap between the product's theoretical quantity and its counted quantity.",
+ readonly=True, digits='Product Unit of Measure', groups='stock.group_stock_manager')
+ inventory_date = fields.Date(
+ 'Scheduled Date', compute='_compute_inventory_date', store=True, readonly=False,
+ help="Next date the On Hand Quantity should be counted.")
+ user_id = fields.Many2one(
+ 'res.users', 'Assigned To', groups='stock.group_stock_manager', help="User assigned to do product count.")
+
@api.depends('quantity', 'reserved_quantity')
def _compute_available_quantity(self):
for quant in self:
quant.available_quantity = quant.quantity - quant.reserved_quantity
- @api.depends('quantity')
- def _compute_inventory_quantity(self):
- if not self._is_inventory_mode():
- self.inventory_quantity = 0
- return
- for quant in self:
- quant.inventory_quantity = quant.quantity
+ @api.depends('location_id')
+ def _compute_inventory_date(self):
+ quants = self.filtered(lambda q: not q.inventory_date and q.location_id.usage in ['internal', 'transit'])
+ date_by_location = {loc: loc._get_next_inventory_date() for loc in quants.location_id}
+ for quant in quants:
+ quant.inventory_date = date_by_location[quant.location_id]
- 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
+ @api.depends('inventory_quantity')
+ def _compute_inventory_diff_quantity(self):
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 = quant._get_inventory_move_values(diff, quant.product_id.with_company(quant.company_id).property_stock_inventory, quant.location_id)
- else:
- move_vals = quant._get_inventory_move_values(-diff, quant.location_id, quant.product_id.with_company(quant.company_id).property_stock_inventory, out=True)
- move = quant.env['stock.move'].with_context(inventory_mode=False).create(move_vals)
- move._action_done()
+ quant.inventory_diff_quantity = quant.inventory_quantity - quant.quantity
def _search_on_hand(self, operator, value):
"""Handle the "on_hand" filter, indirectly calling `_get_domain_locations`."""
@@ -166,6 +160,9 @@ class StockQuant(models.Model):
quant = self.sudo().create(vals)
# Set the `inventory_quantity` field to create the necessary move.
quant.inventory_quantity = inventory_quantity
+ quant.user_id = vals.get('user_id', self.env.user.id)
+ quant.inventory_date = fields.Date.today()
+
return quant
res = super(StockQuant, self).create(vals)
if self._is_inventory_mode():
@@ -184,8 +181,8 @@ class StockQuant(models.Model):
fields.append('reserved_quantity')
result = super(StockQuant, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
for group in result:
- if self._is_inventory_mode():
- group['inventory_quantity'] = group.get('quantity', 0)
+ if self.env.context.get('inventory_report_mode'):
+ group['inventory_quantity'] = False
if 'available_quantity' in fields:
group['available_quantity'] = group['quantity'] - group['reserved_quantity']
return result
@@ -200,7 +197,11 @@ class StockQuant(models.Model):
if any(field for field in vals.keys() if field not in allowed_fields):
raise UserError(_("Quant's editing is restricted, you can't do this operation."))
self = self.sudo()
- return super(StockQuant, self).write(vals)
+ res = super(StockQuant, self).write(vals)
+ if res and self.env.context.get('inventory_report_mode'):
+ # update context to prevent recursive write call
+ self.with_context({'inventory_report_mode': False}).action_apply_inventory()
+ return res
return super(StockQuant, self).write(vals)
def action_view_stock_moves(self):
@@ -221,17 +222,116 @@ class StockQuant(models.Model):
@api.model
def action_view_quants(self):
self = self.with_context(search_default_internal_loc=1)
- if not self.user_has_groups('stock.group_stock_multi_locations'):
- company_user = self.env.company
- 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)
+ self = self._set_view_context()
return self._get_quants_action(extend=True)
+ @api.model
+ def action_view_inventory(self):
+ """ Similar to _get_quants_action except specific for inventory adjustments (i.e. inventory counts). """
+ self = self._set_view_context()
+ self._quant_tasks()
+
+ ctx = dict(self.env.context or {})
+ ctx['no_at_date'] = True
+ action = {
+ 'name': _('Stock On Hand'),
+ 'view_mode': 'list',
+ 'view_id': self.env.ref('stock.view_stock_quant_tree_inventory_editable').id,
+ 'res_model': 'stock.quant',
+ 'type': 'ir.actions.act_window',
+ 'context': ctx,
+ 'domain': [('location_id.usage', 'in', ['internal', 'transit'])],
+ 'help': """
+
+ Want to speed up your inventory counts? Try our Barcode app
+
+ Barcode scanner can be activated via inventory settings.
+ Manual inventory adjustments can also be performed and pre-filled with
+ suggested counted quantity.
+
+ """
+ }
+ return action
+
+ def action_apply_inventory(self):
+ quants_outdated = []
+ products_tracked_without_lot = []
+ for quant in self:
+ rounding = quant.product_uom_id.rounding
+ if fields.Float.is_zero(quant.inventory_diff_quantity, precision_rounding=rounding)\
+ and fields.Float.is_zero(quant.inventory_quantity, precision_rounding=rounding)\
+ and fields.Float.is_zero(quant.quantity, precision_rounding=rounding):
+ continue
+ if fields.Float.compare(quant.inventory_quantity - quant.inventory_diff_quantity, quant.quantity, precision_rounding=rounding):
+ quants_outdated.append(quant.id)
+ if quant.product_id.tracking in ['lot', 'serial'] and\
+ not quant.lot_id and quant.inventory_quantity != quant.quantity:
+ products_tracked_without_lot.append(quant.product_id.id)
+ # for some reason if multi-record, env.context doesn't pass to wizards...
+ ctx = dict(self.env.context or {})
+ ctx['default_quant_ids'] = self.ids
+ if quants_outdated:
+ ctx['default_quant_to_fix_ids'] = quants_outdated
+ return {
+ 'name': _('Conflict in Inventory Adjustment'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'views': [(False, 'form')],
+ 'res_model': 'stock.inventory.conflict',
+ 'target': 'new',
+ 'context': ctx,
+ }
+ if products_tracked_without_lot:
+ ctx['default_product_ids'] = products_tracked_without_lot
+ return {
+ 'name': _('Tracked Products in Inventory Adjustment'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'views': [(False, 'form')],
+ 'res_model': 'stock.track.confirmation',
+ 'target': 'new',
+ 'context': ctx,
+ }
+ self._apply_inventory()
+
+ def action_inventory_history(self):
+ self.ensure_one()
+ action = {
+ 'name': _('History'),
+ 'view_mode': 'list,form',
+ 'res_model': 'stock.move.line',
+ 'views': [(self.env.ref('stock.view_move_line_tree').id, 'list'), (False, 'form')],
+ 'type': 'ir.actions.act_window',
+ 'context': {
+ 'search_default_inventory': 1,
+ 'search_default_done': 1,
+ },
+ 'domain': [
+ ('product_id', '=', self.product_id.id),
+ ('company_id', '=', self.company_id.id),
+ '|',
+ ('location_id', '=', self.location_id.id),
+ ('location_dest_id', '=', self.location_id.id),
+ ],
+ }
+ if self.lot_id:
+ action['context']['search_default_lot_id'] = self.lot_id.id
+ if self.package_id:
+ action['context']['search_default_package_id'] = self.package_id.id
+ action['context']['search_default_result_package_id'] = self.package_id.id
+ if self.owner_id:
+ action['context']['search_default_owner_id'] = self.owner_id.id
+ return action
+
+ def action_set_inventory_quantity(self):
+ for quant in self:
+ quant.inventory_quantity = quant.quantity
+ self.user_id = self.env.user.id
+
+ def action_set_inventory_quantity_to_zero(self):
+ self.inventory_quantity = 0
+ self.inventory_diff_quantity = 0
+
@api.constrains('product_id')
def check_product_id(self):
if any(elem.product_id.type != 'product' for elem in self):
@@ -341,18 +441,16 @@ class StockQuant(models.Model):
if self.tracking == 'none' or self.product_id != self.lot_id.product_id:
vals['lot_id'] = None
- 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)
- reserved_quantity = sum(quants.mapped('reserved_quantity'))
- quantity = sum(quants.mapped('quantity'))
+ quant = 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)
+ if quant:
+ self.quantity = quant.quantity
- vals['reserved_quantity'] = reserved_quantity
- # Update `quantity` only if the user manually updated `inventory_quantity`.
- if float_compare(self.quantity, self.inventory_quantity, precision_rounding=self.product_uom_id.rounding) == 0:
- vals['quantity'] = quantity
# Special case: directly set the quantity to one for serial numbers,
# it'll trigger `inventory_quantity` compute.
if self.lot_id and self.tracking == 'serial':
- vals['quantity'] = 1
+ vals['inventory_quantity'] = 1
if vals:
self.update(vals)
@@ -378,6 +476,30 @@ class StockQuant(models.Model):
if message:
return {'warning': {'title': _('Warning'), 'message': message}}
+ def _apply_inventory(self):
+ move_vals = []
+ for quant in self:
+ # Create and validate a move so that the quant matches its `inventory_quantity`.
+ if float_compare(quant.inventory_diff_quantity, 0, precision_rounding=quant.product_uom_id.rounding) > 0:
+ move_vals.append(
+ quant._get_inventory_move_values(quant.inventory_diff_quantity,
+ quant.product_id.with_company(quant.company_id).property_stock_inventory,
+ quant.location_id))
+ else:
+ move_vals.append(
+ quant._get_inventory_move_values(-quant.inventory_diff_quantity,
+ quant.location_id,
+ quant.product_id.with_company(quant.company_id).property_stock_inventory,
+ out=True))
+ moves = self.env['stock.move'].with_context(inventory_mode=False).create(move_vals)
+ moves._action_done()
+ self.location_id.write({'last_inventory_date': fields.Date.today()})
+ date_by_location = {loc: loc._get_next_inventory_date() for loc in self.mapped('location_id')}
+ for quant in self:
+ quant.inventory_date = date_by_location[quant.location_id]
+ self.write({'inventory_quantity': 0, 'user_id': False})
+ self.write({'inventory_diff_quantity': 0})
+
@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
@@ -498,8 +620,10 @@ class StockQuant(models.Model):
"""
precision_digits = max(6, self.sudo().env.ref('product.decimal_product_uom').digits * 2)
# Use a select instead of ORM search for UoM robustness.
- query = """SELECT id FROM stock_quant WHERE (round(quantity::numeric, %s) = 0 OR quantity IS NULL) AND round(reserved_quantity::numeric, %s) = 0;"""
- params = (precision_digits, precision_digits)
+ query = """SELECT id FROM stock_quant WHERE (round(quantity::numeric, %s) = 0 OR quantity IS NULL)
+ AND round(reserved_quantity::numeric, %s) = 0
+ AND (round(inventory_quantity::numeric, %s) = 0 OR inventory_quantity IS NULL);"""
+ params = (precision_digits, precision_digits, precision_digits)
self.env.cr.execute(query, params)
quant_ids = self.env['stock.quant'].browse([quant['id'] for quant in self.env.cr.dictfetchall()])
quant_ids.sudo().unlink()
@@ -516,6 +640,7 @@ class StockQuant(models.Model):
SELECT min(id) as to_update_quant_id,
(array_agg(id ORDER BY id))[2:array_length(array_agg(id), 1)] as to_delete_quant_ids,
SUM(reserved_quantity) as reserved_quantity,
+ SUM(inventory_quantity) as inventory_quantity,
SUM(quantity) as quantity,
MIN(in_date) as in_date
FROM stock_quant
@@ -526,6 +651,7 @@ class StockQuant(models.Model):
UPDATE stock_quant q
SET quantity = d.quantity,
reserved_quantity = d.reserved_quantity,
+ inventory_quantity = d.inventory_quantity,
in_date = d.in_date
FROM dupes d
WHERE d.to_update_quant_id = q.id
@@ -550,19 +676,19 @@ class StockQuant(models.Model):
"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')
+ return self.env.context.get('inventory_mode') 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']
+ return ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id'] + self._get_inventory_fields_write()
@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']
+ return ['inventory_quantity', 'inventory_diff_quantity', 'inventory_date', 'user_id']
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`)
@@ -574,8 +700,12 @@ class StockQuant(models.Model):
:return: dict with all values needed to create a new `stock.move` with its move line.
"""
self.ensure_one()
+ if fields.Float.is_zero(qty, 0, precision_rounding=self.product_uom_id.rounding):
+ name = _('Product Quantity Confirmed')
+ else:
+ name = _('Product Quantity Updated')
return {
- 'name': _('Product Quantity Updated'),
+ 'name': name,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': qty,
@@ -583,6 +713,7 @@ class StockQuant(models.Model):
'state': 'confirmed',
'location_id': location_id.id,
'location_dest_id': location_dest_id.id,
+ 'is_inventory': True,
'move_line_ids': [(0, 0, {
'product_id': self.product_id.id,
'product_uom_id': self.product_uom_id.id,
@@ -597,9 +728,22 @@ class StockQuant(models.Model):
})]
}
+ def _set_view_context(self):
+ """ Adds context when opening quants related views. """
+ if not self.user_has_groups('stock.group_stock_multi_locations'):
+ company_user = self.env.company
+ 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, hide_location=True)
+
+ # 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
+
@api.model
def _get_quants_action(self, domain=None, extend=False):
- """ Returns an action to open quant view.
+ """ Returns an action to open (non-inventory adjustment) quant view.
Depending of the context (user have right to be inventory mode or not),
the list view will be editable or readonly.
@@ -608,6 +752,7 @@ class StockQuant(models.Model):
"""
self._quant_tasks()
ctx = dict(self.env.context or {})
+ ctx['inventory_report_mode'] = True
ctx.pop('group_by', None)
action = {
'name': _('Stock On Hand'),
diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py
index 7227579915c..f25050e5d5b 100644
--- a/addons/stock/models/stock_rule.py
+++ b/addons/stock/models/stock_rule.py
@@ -517,12 +517,6 @@ class ProcurementGroup(models.Model):
if use_new_cursor:
self._cr.commit()
- # Run cyclic inventories
- self.env['stock.inventory']._run_inventory_tasks(company_id)
-
- if use_new_cursor:
- self._cr.commit()
-
@api.model
def run_scheduler(self, use_new_cursor=False, company_id=False):
""" Call the scheduler in order to check the running procurements (super method), to check the minimum stock rules
diff --git a/addons/stock/populate/stock.py b/addons/stock/populate/stock.py
index 7527bf2904e..b97eac92922 100644
--- a/addons/stock/populate/stock.py
+++ b/addons/stock/populate/stock.py
@@ -303,139 +303,6 @@ class StockWarehouseOrderpoint(models.Model):
]
-class Inventory(models.Model):
- _inherit = 'stock.inventory'
-
- _populate_sizes = {'small': 5, 'medium': 10, 'large': 20}
- _populate_dependencies = ['stock.location', 'product.product']
-
- def _populate(self, size):
- inventories = super()._populate(size)
-
- def start_inventory_sample(sample_ratio):
- random = populate.Random('start_inventory_sample')
- inventories_to_start = self.browse(random.sample(inventories.ids, int(len(inventories.ids) * sample_ratio)))
- # Start empty to let the stock.inventory.line populate create lines
- inventories_to_start.start_empty = True
- for inventory in inventories_to_start:
- inventory.action_start()
-
- # Start 80 % of adjustment inventory
- start_inventory_sample(0.8)
-
- return inventories
-
- def _populate_factories(self):
-
- company_ids = self.env.registry.populated_models['res.company'][:COMPANY_NB_WITH_STOCK]
- valid_locations = self.env['stock.location'].search([
- ('company_id', 'in', company_ids),
- ('usage', 'in', ['internal', 'transit'])
- ])
- locations_by_company = dict(groupby(valid_locations, key=lambda loc: loc.company_id.id))
- locations_by_company = {company_id: self.env['stock.location'].concat(*locations) for company_id, locations in locations_by_company.items()}
- products = self.env['product.product'].browse(self.env.registry.populated_models['product.product']).filtered(lambda p: p.type == 'product')
-
- def get_locations_ids(values, counter, random):
- location_ids_company = locations_by_company[values['company_id']]
- # We set locations to ease the creation of stock.inventory.line
- # Should be larger enough to avoid the emptiness of generator_product_loc_dict
- return random.sample(location_ids_company.ids, int(len(location_ids_company.ids) * 0.5))
-
- def get_product_ids(values, counter, random):
- # We set products to ease the creation of stock.inventory.line
- # Should be larger enough to avoid the emptiness of generator_product_loc_dict
- return random.sample(products.ids, int(len(products.ids) * 0.5))
-
- return [
- ('name', populate.constant("Inventory-Pop-{counter}")),
- ('company_id', populate.iterate(company_ids)),
- ('location_ids', populate.compute(get_locations_ids)),
- ('product_ids', populate.compute(get_product_ids)),
- ]
-
-
-class InventoryLine(models.Model):
- _inherit = 'stock.inventory.line'
-
- _populate_sizes = {'small': 500, 'medium': 5_000, 'large': 20_000}
- _populate_dependencies = ['stock.inventory']
-
- def _populate(self, size):
- inventory_lines = super()._populate(size)
-
- def create_missing_lots():
- _logger.info("Create lot/serial for inventory line to be ready to validate")
- lots_values = []
- for line in inventory_lines:
- if line.product_tracking == 'none':
- continue
- lots_values.append({
- 'name': "InLine-%d" % line.id,
- 'product_id': line.product_id.id,
- 'company_id': line.inventory_id.company_id.id
- })
- lots = self.env['stock.production.lot'].create(lots_values).ids[::-1]
- for line in inventory_lines:
- if line.product_tracking == 'none':
- continue
- line.prod_lot_id = lots.pop()
-
- def validate_inventory_sample(sample_ratio):
- # Validate the inventory adjustment, useful to have a stock at beginning
- inventories_ids = inventory_lines.inventory_id.ids
- random = populate.Random('validate_inventory')
- inventories_to_done = self.env['stock.inventory'].browse(random.sample(inventories_ids, int(len(inventories_ids) * sample_ratio)))
- _logger.info("Validate %d inventory adjustment" % len(inventories_to_done))
- for inventory in inventories_to_done:
- inventory.action_validate()
-
- # Create missing tracking lot/serial in batch
- create_missing_lots()
-
- # (Un)comment to test a DB with a current stock.
- # validate_inventory_sample(0.8)
-
- return inventory_lines
-
- def _populate_factories(self):
-
- inventories = self.env['stock.inventory'].browse(self.env.registry.populated_models['stock.inventory'])
- inventories = inventories.filtered(lambda i: i.state == 'confirm')
-
- def compute_product_location(iterator, field_name, model_name):
- random = populate.Random('compute_product_location')
-
- # To avoid create twice the same line inventory (avoid _check_no_duplicate_line) : product/location (limitation for lot)
- # Use generator to avoid cartisian product in memory
- generator_product_loc_dict = {}
- for inventory in inventories:
- # TODO: randomize cartesian product
- generator_product_loc_dict[inventory.id] = cartesian_product(
- random.sample(inventory.location_ids, len(inventory.location_ids)),
- random.sample(inventory.product_ids, len(inventory.product_ids))
- )
-
- for values in iterator:
- loc_id, product = next(generator_product_loc_dict[values['inventory_id']])
- values['product_id'] = product.id
- values['location_id'] = loc_id.id
- yield values
-
- def get_product_qty(values, counter, random):
- product = self.env['product.product'].browse(values['product_id'])
- if product.tracking == 'serial':
- return 1.0
- else:
- return random.randint(5, 25)
-
- return [
- ('inventory_id', populate.iterate(inventories.ids)),
- ('_compute_product_location', compute_product_location),
- ('product_qty', populate.compute(get_product_qty)),
- ]
-
-
class PickingType(models.Model):
_inherit = 'stock.picking.type'
diff --git a/addons/stock/report/report_stockinventory.xml b/addons/stock/report/report_stockinventory.xml
index 2aff98f17dd..9a1b7a4d929 100644
--- a/addons/stock/report/report_stockinventory.xml
+++ b/addons/stock/report/report_stockinventory.xml
@@ -3,54 +3,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
- | Location |
- Product |
- Lot/Serial Number |
- Package |
- On Hand Quantity |
- Counted Quantity |
+
+
+
+
Inventory Count Sheet
+
+
+
+
+ | Location |
+ Product |
+ Lot/Serial Number |
+ Package |
+ Available Quantity |
+ On hand Quantity |
+ Counted Quantity |
+
+
+
+
+
+ |
+ |
+ |
+ |
-
-
-
-
- |
- |
- |
- |
-
-
- |
- |
- |
- |
- |
- |
-
-
-
-
-
-
-
-
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
diff --git a/addons/stock/report/stock_report_views.xml b/addons/stock/report/stock_report_views.xml
index dea0838fc60..cff99953af5 100644
--- a/addons/stock/report/stock_report_views.xml
+++ b/addons/stock/report/stock_report_views.xml
@@ -23,12 +23,12 @@