From 3d2429f69a18dd4f5c3e415be95ef09a3e7f48ca Mon Sep 17 00:00:00 2001 From: "Tiffany Chang (tic)" Date: Mon, 5 Oct 2020 08:56:38 +0000 Subject: [PATCH] [IMP] stock: add cyclic inventory for locations Add a new feature to allow automated (as part of scheduler) generation of inventory adjustments based on user set value for how often they should be done for 'internal' and 'transit' locations. - When is the inventory created? When cyclic frequency is set to a non-zero value, the next date an inventory should be automatically generated for a location is based on whether or not a previous inventory for that location was completed. If an inventory is overdue based on the previous inventory date, one will be scheduled for tomorrow. If no previous inventory date exists, schedule the next one based on today's date. A new inventory will be created by the scheduler if there is not an existing in progress inventory for the location (to avoid a conflict). - What about descendant locations? Descendent locations will not have an inventory created if their ancestor is supposed to have a cyclic inventory done at the same time. Note this includes when that ancestory already has an in progress inventory and isn't having a new one created. - What about cleaning out of date cyclic inventories? This automation will also delete existing draft inventories that include this location (created before the day the scheduler is run) or any of its descendants (i.e. inventories with only that descendant location selected to avoid deleting obviously manually created inventories). Task: 2336455 Upgrade PR: odoo/upgrade#1839 --- addons/stock/models/stock_inventory.py | 44 ++++++++++++++++++ addons/stock/models/stock_location.py | 26 ++++++++++- addons/stock/models/stock_quant.py | 3 +- addons/stock/models/stock_rule.py | 6 +++ addons/stock/tests/test_inventory.py | 49 +++++++++++++++++++++ addons/stock/views/stock_location_views.xml | 5 +++ 6 files changed, 130 insertions(+), 3 deletions(-) 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 @@ + + + + +