diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index 560b43e0ae1..4e69f85abb7 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -125,6 +125,9 @@ class Inventory(models.Model):
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):
@@ -319,6 +322,47 @@ class Inventory(models.Model):
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):
+ """ Generate and 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
+ """
+ domain = [('next_inventory_date', '<=', fields.Date.today())]
+ if company_id:
+ # manually triggered => only apply to user's companies
+ domain = expression.AND([[('company_id', '=', company_id)], domain])
+ else:
+ # cron triggered => apply to all companies
+ domain = expression.AND([[('company_id', '!=', False)], 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)
+
class InventoryLine(models.Model):
_name = "stock.inventory.line"
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 5013038e686..3c44faeff3b 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import timedelta
+
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
@@ -60,8 +62,12 @@ 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.")
+ 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.")
- _sql_constraints = [('barcode_company_uniq', 'unique (barcode,company_id)', 'The barcode for a location must be unique per company !')]
+ _sql_constraints = [('barcode_company_uniq', 'unique (barcode,company_id)', 'The barcode for a location must be unique per company !'),
+ ('inventory_freq_nonneg', 'check(cyclic_inventory_frequency >= 0)', 'The inventory frequency (days) for a location must be non-negative')]
@api.depends('name', 'location_id.complete_name')
def _compute_complete_name(self):
@@ -71,6 +77,24 @@ class Location(models.Model):
else:
location.complete_name = location.name
+ @api.depends('cyclic_inventory_frequency', 'last_inventory_date', 'usage', 'company_id')
+ def _compute_next_inventory_date(self):
+ for location in self:
+ 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
+ if days_until_next_inventory <= 0:
+ location.next_inventory_date = fields.Date.today() + timedelta(days=1)
+ else:
+ location.next_inventory_date = location.last_inventory_date + timedelta(days=days_until_next_inventory)
+ else:
+ location.next_inventory_date = fields.Date.today() + timedelta(days=location.cyclic_inventory_frequency)
+ except OverflowError:
+ raise UserError(_("The selected Inventory Frequency (Days) creates a date too far into the future."))
+ else:
+ location.next_inventory_date = False
+
@api.onchange('usage')
def _onchange_usage(self):
if self.usage not in ('internal', 'inventory'):
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
index e7da386f1dd..4b27fff15e1 100644
--- a/addons/stock/models/stock_quant.py
+++ b/addons/stock/models/stock_quant.py
@@ -726,8 +726,7 @@ class QuantPackage(models.Model):
# Quant clean-up, mostly to avoid multiple quants of the same product. For example, unpack
# 2 packages of 50, then reserve 100 => a quant of -50 is created at transfer validation.
- self.env['stock.quant']._merge_quants()
- self.env['stock.quant']._unlink_zero_quants()
+ self.env['stock.quant']._quant_tasks()
def action_view_picking(self):
action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all")
diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py
index 073fb3c9200..def3cd8cf44 100644
--- a/addons/stock/models/stock_rule.py
+++ b/addons/stock/models/stock_rule.py
@@ -521,6 +521,12 @@ 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/tests/test_inventory.py b/addons/stock/tests/test_inventory.py
index 7cfd2b12924..e1230e5af44 100644
--- a/addons/stock/tests/test_inventory.py
+++ b/addons/stock/tests/test_inventory.py
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import datetime, timedelta
+
from odoo.exceptions import ValidationError
from odoo.tests.common import Form, TransactionCase
@@ -802,3 +804,50 @@ class TestInventory(TransactionCase):
self.assertEqual(len(line_ids_loc2), 1)
self.assertEqual(line_ids_loc1[0].theoretical_qty, 10)
self.assertEqual(line_ids_loc2[0].theoretical_qty, 0)
+
+ def test_cyclic_inventory(self):
+ """ Check that a location with a cyclic inventory set has its inventory
+ properly auto-generate and correctly record relevant dates.
+ """
+ grp_multi_loc = self.env.ref('stock.group_stock_multi_locations')
+ self.env.user.write({'groups_id': [(4, grp_multi_loc.id)]})
+ now = datetime.now()
+ today = now.date()
+
+ new_loc = self.env['stock.location'].create({
+ 'name': 'New Cyclic Inv Location',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ })
+
+ existing_loc2 = self.env['stock.location'].create({
+ 'name': 'Pre-existing Cyclic Inv Location',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'last_inventory_date': now - timedelta(days=5),
+ })
+
+ new_loc_form = Form(new_loc)
+ new_loc_form.cyclic_inventory_frequency = 2
+ new_loc = new_loc_form.save()
+
+ # check next_inventory_date is correctly calculated
+ existing_loc2_form = Form(existing_loc2)
+ existing_loc2_form.cyclic_inventory_frequency = 2
+ existing_loc2 = existing_loc2_form.save()
+
+ # next_inventory_date = today + cyclic_inventory_frequency
+ self.assertEqual(new_loc.next_inventory_date, today + timedelta(days=2))
+ # previous inventory done + cyclic_inventory_frequency < today => next_inventory_date = tomorrow
+ self.assertEqual(existing_loc2.next_inventory_date, today + timedelta(days=1))
+
+ # check that cyclic inventories are correctly autogenerated
+ new_loc.next_inventory_date = today - timedelta(days=2)
+ Inventory = self.env['stock.inventory']
+ Inventory._run_inventory_tasks()
+ cyclic_inventories = Inventory.search([('location_ids', 'in', [new_loc.id, existing_loc2.id])])
+ # only new_loc should have triggered an inventory
+ self.assertEqual(len(cyclic_inventories), 1)
+ cyclic_inventories._action_done()
+ self.assertEqual(new_loc.next_inventory_date, today + timedelta(days=2))
+ self.assertEqual(new_loc.last_inventory_date.date(), today)
diff --git a/addons/stock/views/stock_location_views.xml b/addons/stock/views/stock_location_views.xml
index 0c01cdb1c0e..0e09877a92c 100644
--- a/addons/stock/views/stock_location_views.xml
+++ b/addons/stock/views/stock_location_views.xml
@@ -39,6 +39,11 @@
+
+
+
+
+