[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:
committed by
Arnold Moyaux
parent
b1dff85a85
commit
3d2429f69a
@@ -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"
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user