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 @@ + + + + +