[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
This commit is contained in:
Tiffany Chang (tic)
2020-11-24 09:38:37 +00:00
committed by Arnold Moyaux
parent b1dff85a85
commit 3d2429f69a
6 changed files with 130 additions and 3 deletions
+44
View File
@@ -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"
+25 -1
View File
@@ -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'):
+1 -2
View File
@@ -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")
+6
View File
@@ -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
+49
View File
@@ -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)
@@ -39,6 +39,11 @@
<field name="scrap_location" attrs="{'invisible': [('usage', 'not in', ('inventory', 'internal'))]}"/>
<field name="return_location"/>
</group>
<group string="Cyclic Inventory" attrs="{'invisible': ['|', ('usage', 'not in', ('internal', 'transit')), ('company_id', '=', False)]}">
<field name="cyclic_inventory_frequency"/>
<field name="last_inventory_date"/>
<field name="next_inventory_date" attrs="{'invisible': [('active', '=', False)]}"/>
</group>
<group string="Logistics" groups="stock.group_adv_location">
<field name="removal_strategy_id" options="{'no_create': True}"/>
</group>