Add stock_move_auto_assign

Automatically check availability of stock moves when a move is set to "done".

It uses queue jobs to verify the availability in order to have a minimal impact
on the user operations.

The conditions to trigger the check are:

* A move is marked as done
* The destination locations of the move lines are internal
* The move doesn't have successors in a chain of moves

At this point, jobs are generated:

* One job per product
* Any move waiting for stock in a parent (or same) location of the internal
  destination locations from the done move has its availability checked

Only one job is generated for an identical set of (product, locations).
This commit is contained in:
Guewen Baconnier
2020-06-12 16:12:12 +02:00
parent 6e00f29d20
commit d3b1dbdd05
11 changed files with 277 additions and 0 deletions

View File

@@ -0,0 +1 @@
../../../../stock_move_auto_assign

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,20 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Stock Move Auto Assign",
"summary": "Try to reserve moves when goods enter in a location",
"version": "13.0.1.0.0",
"author": "Camptocamp, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/stock-logistics-warehouse",
"category": "Stock Management",
"depends": [
"stock",
# OCA/queue
"queue_job",
],
"data": [],
"installable": True,
"development_status": "Beta",
"license": "AGPL-3",
}

View File

@@ -0,0 +1,2 @@
from . import stock_move
from . import product_product

View File

@@ -0,0 +1,41 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import models
from odoo.addons.queue_job.job import job
class ProductProduct(models.Model):
_inherit = "product.product"
def _moves_auto_assign_domain(self, locations):
return [
("product_id", "=", self.id),
("location_id", "parent_of", locations.ids),
("state", "in", ("confirmed", "partially_available")),
# useless to try reserving a move that waits on another move
# or is MTO
("move_orig_ids", "=", False),
("procure_method", "=", "make_to_stock"),
# Do not filter on product_id.type by default because it uses an
# additional query on product_product and product_template.
# StockMove._prepare_auto_assign() already filtered out
# non-stockable products and # `_action_assign()` would filter them
# out anyway.
]
@job(default_channel="root.stock_auto_assign")
def moves_auto_assign(self, locations):
"""Try to reserve moves based on product and locations
When a product has been added to a location, it searches all*
the moves with a source equal or above this location and try
to reserve them.
* all the moves that would make sense to reserve, so no chained
moves, no MTO, ...
"""
self.ensure_one()
moves = self.env["stock.move"].search(self._moves_auto_assign_domain(locations))
moves._action_assign()

View File

@@ -0,0 +1,57 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from collections import defaultdict
from odoo import _, models
from odoo.addons.queue_job.job import identity_exact
class StockMove(models.Model):
_inherit = "stock.move"
def _action_done(self, cancel_backorder=False):
done_moves = super()._action_done(cancel_backorder=cancel_backorder)
done_moves._prepare_auto_assign()
return done_moves
def _prepare_auto_assign(self):
product_locs = defaultdict(set)
for move in self:
# select internal locations where we moved goods not for the
# purpose of another move (so no further destination move)
if move.move_dest_ids:
continue
product = move.product_id
if product.type != "product":
continue
locations = move.mapped("move_line_ids.location_dest_id").filtered(
lambda l: l.usage == "internal"
)
product_locs[product.id].update(locations.ids)
for product_id, location_ids in product_locs.items():
if not location_ids:
continue
self._enqueue_auto_assign(
self.env["product.product"].browse(product_id),
self.env["stock.location"].browse(location_ids),
)
def _enqueue_auto_assign(self, product, locations, **job_options):
"""Enqueue a job ProductProduct.moves_auto_assign()
Can be extended to pass different options to the job (priority, ...).
The usage of `.setdefault` allows to override the options set by default.
"""
job_options = job_options.copy()
job_options.setdefault(
"description",
_('Try reserving "{}" for quantities added in: {}').format(
product.display_name, ", ".join(locations.mapped("name"))
),
)
# do not enqueue 2 jobs for the same product and locations set
job_options.setdefault("identity_key", identity_exact)
product.with_delay(**job_options).moves_auto_assign(locations)

View File

@@ -0,0 +1 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>

View File

@@ -0,0 +1,18 @@
Automatically check availability of stock moves when a move is set to "done".
It uses queue jobs to verify the availability in order to have a minimal impact
on the user operations.
The conditions to trigger the check are:
* A move is marked as done
* The destination locations of the move lines are internal
* The move doesn't have successors in a chain of moves
At this point, jobs are generated:
* One job per product
* Any move waiting for stock in a parent (or same) location of the internal
destination locations from the done move has its availability checked
Only one job is generated for an identical set of (product, locations).

View File

@@ -0,0 +1 @@
from . import test_auto_assign

View File

@@ -0,0 +1,129 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import SavepointCase
from odoo.addons.queue_job.job import identity_exact
from odoo.addons.queue_job.tests.common import mock_with_delay
class TestStockMoveAutoAssign(SavepointCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.wh = cls.env.ref("stock.warehouse0")
cls.out_type = cls.wh.out_type_id
cls.in_type = cls.wh.in_type_id
cls.int_type = cls.wh.int_type_id
cls.customer_loc = cls.env.ref("stock.stock_location_customers")
cls.supplier_loc = cls.env.ref("stock.stock_location_suppliers")
cls.shelf1_loc = cls.env.ref("stock.stock_location_components")
cls.shelf2_loc = cls.env.ref("stock.stock_location_14")
cls.product = cls.env["product.product"].create(
{"name": "Product", "type": "product"}
)
def _create_move(
self,
product,
picking_type,
qty=1.0,
state="confirmed",
procure_method="make_to_stock",
move_dest=None,
):
source = picking_type.default_location_src_id or self.supplier_loc
dest = picking_type.default_location_dest_id or self.customer_loc
move_vals = {
"name": product.name,
"product_id": product.id,
"product_uom_qty": qty,
"product_uom": product.uom_id.id,
"picking_type_id": picking_type.id,
"location_id": source.id,
"location_dest_id": dest.id,
"state": state,
"procure_method": procure_method,
}
if move_dest:
move_vals["move_dest_ids"] = [(4, move_dest.id, False)]
return self.env["stock.move"].create(move_vals)
def _update_qty_in_location(self, location, product, quantity):
self.env["stock.quant"]._update_available_quantity(product, location, quantity)
def test_job_assign_confirmed_move(self):
"""Test job method, assign moves matching product and location"""
move1 = self._create_move(self.product, self.out_type)
move2 = self._create_move(self.product, self.out_type)
# put stock in Stock/Shelf 1, the move has a source location in Stock
self._update_qty_in_location(self.shelf1_loc, self.product, 100)
self.product.moves_auto_assign(self.shelf1_loc)
self.assertEqual(move1.state, "assigned")
self.assertEqual(move2.state, "assigned")
def test_move_done_enqueue_job(self):
"""A move done enqueue a new job to assign other moves"""
move = self._create_move(self.product, self.in_type, qty=100)
move._action_assign()
move.move_line_ids.qty_done = 50
move.move_line_ids.location_dest_id = self.shelf1_loc.id
move.move_line_ids.copy(
default={"qty_done": 50, "location_dest_id": self.shelf2_loc.id}
)
with mock_with_delay() as (delayable_cls, delayable):
move._action_done()
# .with_delay() has been called once
self.assertEqual(delayable_cls.call_count, 1)
delay_args, delay_kwargs = delayable_cls.call_args
# .with_delay() is called on self.product
self.assertEqual(delay_args, (self.product,))
# .with_delay() with the following options
self.assertEqual(delay_kwargs.get("identity_key"), identity_exact)
# check what's passed to the job method 'moves_auto_assign'
self.assertEqual(delayable.moves_auto_assign.call_count, 1)
delay_args, delay_kwargs = delayable.moves_auto_assign.call_args
self.assertEqual(delay_args, (self.shelf1_loc | self.shelf2_loc,))
self.assertDictEqual(delay_kwargs, {})
def test_move_done_service_no_job(self):
"""Service products do not enqueue job"""
self.product.type = "service"
move = self._create_move(self.product, self.in_type, qty=1)
move._action_assign()
move.move_line_ids.qty_done = 1
move.move_line_ids.location_dest_id = self.shelf1_loc.id
with mock_with_delay() as (delayable_cls, delayable):
move._action_done()
# .with_delay() has not been called
self.assertEqual(delayable_cls.call_count, 0)
def test_move_done_chained_no_job(self):
"""A move chained to another does not enqueue job"""
move_out = self._create_move(
self.product, self.out_type, qty=1, state="waiting"
)
move = self._create_move(self.product, self.in_type, qty=1, move_dest=move_out)
move._action_assign()
move.move_line_ids.qty_done = 1
move.move_line_ids.location_dest_id = self.shelf1_loc.id
with mock_with_delay() as (delayable_cls, delayable):
move._action_done()
# .with_delay() has not been called
self.assertEqual(delayable_cls.call_count, 0)
def test_move_done_customer_no_job(self):
"""A move with other destination than internal does not enqueue job"""
move = self._create_move(self.product, self.out_type, qty=1)
self._update_qty_in_location(self.shelf1_loc, self.product, 1)
move._action_assign()
move.move_line_ids.qty_done = 1
move.move_line_ids.location_dest_id = self.customer_loc
with mock_with_delay() as (delayable_cls, delayable):
move._action_done()
# .with_delay() has not been called
self.assertEqual(delayable_cls.call_count, 0)