From 684bbda313c6f8f80a28d7df3d4ca0a262468be8 Mon Sep 17 00:00:00 2001 From: "Tiffany Chang (tic)" Date: Tue, 6 Oct 2020 10:11:44 +0000 Subject: [PATCH] [IMP] stock: add auto inventory for negative quantities Add a new feature to automatically create/update an inventory per warehouse specific to products with negative qty in any of its locations. The goal of this is to help track inventory discrepencies and prevent appearance of being out of stock when there is still stock left. This is done by having the inventories update/create whenever the "Inventory Adjustments" menuitem is clicked. Since we don't know when a product's stock becomes non-negative, we always re-write all the neg products to existing draft neg inventories and remove any pre-existing draft inventories that haven't been updated since they no longer have any neg qty products anymore. Scheduler will also trigger this recalculation in hopes that it will reduce having a large number of inventories to create when the inventory menuitem isn't opened very often. Note: a flag `is_conflict_inventory` was added to avoid conflicts with manually generated inventories. Task: 2336455 ENT PR: odoo/enterprise#14667 Upgrade PR: odoo/upgrade#1839 --- addons/stock/models/stock_inventory.py | 89 ++++++++++++++++++-- addons/stock/tests/test_inventory.py | 39 +++++++++ addons/stock/views/stock_inventory_views.xml | 23 +++-- 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index 4e69f85abb7..b88e1c2a80b 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -1,6 +1,8 @@ # -*- 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 @@ -63,6 +65,8 @@ class Inventory(models.Model): '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.") @api.onchange('company_id') def _onchange_company_id(self): @@ -239,11 +243,13 @@ class Inventory(models.Model): locations_ids = [l['id'] for l in self.env['stock.location'].search_read(domain_loc, ['id'])] domain = [('company_id', '=', self.company_id.id), - ('quantity', '!=', '0'), ('location_id', 'in', locations_ids)] if self.prefill_counted_quantity == 'zero': domain.append(('product_id.active', '=', True)) - + if self.is_conflict_inventory: + domain.append(('quantity', '<', 0)) + else: + domain.append(('quantity', '!=', 0)) if self.product_ids: domain = expression.AND([domain, [('product_id', 'in', self.product_ids.ids)]]) @@ -324,20 +330,21 @@ class Inventory(models.Model): @api.model def _run_inventory_tasks(self, company_id=False): - """ Generate and clean up cyclic inventories for locations as follows: + """ 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 """ - domain = [('next_inventory_date', '<=', fields.Date.today())] + # if cron triggered => apply to all companies + company_domain = [('company_id', '!=', False)] 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]) + # 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 @@ -362,6 +369,70 @@ class Inventory(models.Model): '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_neg_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_neg_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 + }) + self.create(inventory_vals_to_create) + # remove all obsolete conflict inventories + (existing_conflict_invs.filtered(lambda i: i.state == 'draft') - updated_neg_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): diff --git a/addons/stock/tests/test_inventory.py b/addons/stock/tests/test_inventory.py index e1230e5af44..245422787e7 100644 --- a/addons/stock/tests/test_inventory.py +++ b/addons/stock/tests/test_inventory.py @@ -851,3 +851,42 @@ class TestInventory(TransactionCase): cyclic_inventories._action_done() self.assertEqual(new_loc.next_inventory_date, today + timedelta(days=2)) self.assertEqual(new_loc.last_inventory_date.date(), today) + + def test_neg_qty_conflict_inventory(self): + """ Check that auto-created negative quantity stock inventories work correctly.""" + + Inventory = self.env['stock.inventory'] + # check that neg qty will generate inventory + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, -1) + Inventory._run_conflict_inventory_tasks() + neg_qty_inventories = Inventory.search([('is_conflict_inventory', '=', True)]) + self.assertEqual(len(neg_qty_inventories), 1) + self.assertEqual(neg_qty_inventories.product_ids[0], self.product1) + + # check that inventory auto-updates correctly when still in draft + self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, -1) + self.env['stock.quant']._update_available_quantity(self.product2, self.pack_location, -1) + Inventory._run_conflict_inventory_tasks() + self.assertEqual(len(neg_qty_inventories.product_ids), 2, "New negative quantity product should be auto-added to existing draft inventory") + self.env['stock.quant']._update_available_quantity(self.product2, self.pack_location, 1) + Inventory._run_conflict_inventory_tasks() + self.assertEqual(len(neg_qty_inventories.product_ids), 2, "Only remove 1 of 2 neg quants shouldn't remove product from inventory") + self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1) + Inventory._run_conflict_inventory_tasks() + self.assertEqual(len(neg_qty_inventories.product_ids), 1, "Previously neg quantity product should be auto-removed from existing draft inventory") + + # check that inventory does not auto-update/auto-generate when neg qty inventory is in progress + neg_qty_inventories.action_start() + self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, -1) + Inventory._run_conflict_inventory_tasks() + neg_qty_inventories = Inventory.search([('is_conflict_inventory', '=', True)]) + self.assertEqual(len(neg_qty_inventories), 1) + self.assertEqual(len(neg_qty_inventories.product_ids), 1) + + # check that inventory auto-deletes when no more neg qtys + neg_qty_inventories.action_cancel_draft() + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1) + self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1) + Inventory._run_conflict_inventory_tasks() + neg_qty_inventories = Inventory.search([('is_conflict_inventory', '=', True)]) + self.assertEqual(len(neg_qty_inventories), 0) diff --git a/addons/stock/views/stock_inventory_views.xml b/addons/stock/views/stock_inventory_views.xml index f4d9eda9c82..965e08ab209 100644 --- a/addons/stock/views/stock_inventory_views.xml +++ b/addons/stock/views/stock_inventory_views.xml @@ -176,6 +176,7 @@ +