mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
1
setup/stock_move_auto_assign/odoo/addons/stock_move_auto_assign
Symbolic link
1
setup/stock_move_auto_assign/odoo/addons/stock_move_auto_assign
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../stock_move_auto_assign
|
||||
6
setup/stock_move_auto_assign/setup.py
Normal file
6
setup/stock_move_auto_assign/setup.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
setup_requires=['setuptools-odoo'],
|
||||
odoo_addon=True,
|
||||
)
|
||||
1
stock_move_auto_assign/__init__.py
Normal file
1
stock_move_auto_assign/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
20
stock_move_auto_assign/__manifest__.py
Normal file
20
stock_move_auto_assign/__manifest__.py
Normal 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",
|
||||
}
|
||||
2
stock_move_auto_assign/models/__init__.py
Normal file
2
stock_move_auto_assign/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import stock_move
|
||||
from . import product_product
|
||||
41
stock_move_auto_assign/models/product_product.py
Normal file
41
stock_move_auto_assign/models/product_product.py
Normal 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()
|
||||
57
stock_move_auto_assign/models/stock_move.py
Normal file
57
stock_move_auto_assign/models/stock_move.py
Normal 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)
|
||||
1
stock_move_auto_assign/readme/CONTRIBUTORS.rst
Normal file
1
stock_move_auto_assign/readme/CONTRIBUTORS.rst
Normal file
@@ -0,0 +1 @@
|
||||
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||
18
stock_move_auto_assign/readme/DESCRIPTION.rst
Normal file
18
stock_move_auto_assign/readme/DESCRIPTION.rst
Normal 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).
|
||||
1
stock_move_auto_assign/tests/__init__.py
Normal file
1
stock_move_auto_assign/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_auto_assign
|
||||
129
stock_move_auto_assign/tests/test_auto_assign.py
Normal file
129
stock_move_auto_assign/tests/test_auto_assign.py
Normal 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)
|
||||
Reference in New Issue
Block a user