[ADD] stock_location_package_restriction

This commit is contained in:
Jacques-Etienne Baudoux
2023-05-26 14:56:18 +02:00
parent c0c4231eed
commit e081d8c5d9
14 changed files with 531 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1 @@
Wait for the bot

View File

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

View File

@@ -0,0 +1,17 @@
# Copyright 2023 Raumschmiede (http://www.raumschmiede.de)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Stock Location Package Restriction",
"summary": """
Control if the location can contain products not in a package""",
"version": "14.0.1.0.0",
"category": "Warehouse Management",
"author": "Raumschmiede.de, BCIM, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/stock-logistics-warehouse",
"license": "AGPL-3",
"depends": ["stock"],
"application": False,
"installable": True,
"data": ["views/stock_location.xml"],
}

View File

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

View File

@@ -0,0 +1,40 @@
# Copyright 2023 Raumschmiede (http://www.raumschmiede.de)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
SINGLEPACKAGE = "singlepackage"
MULTIPACKAGE = "multiplepackage"
NORESTRICTION = "norestriction"
class StockLocation(models.Model):
_inherit = "stock.location"
package_restriction = fields.Selection(
selection=lambda self: self._selection_package_restriction(),
help="""
Control if the location can contain products not in a package.
Options:
* Mandatory and unique: The location cannot have products not
part of a package and you cannot have more than 1 package on
the location
* Mandatory and not unique: The location cannot have products
not part of a package and you may have store multiple packages
on the location
* Not mandatory: The location can contain products not part of a
package
""",
required=True,
store=True,
default="norestriction",
)
@api.model
def _selection_package_restriction(self):
return [
(MULTIPACKAGE, "Mandatory"),
(SINGLEPACKAGE, "Mandatory and unique"),
(NORESTRICTION, "Not mandatory"),
]

View File

@@ -0,0 +1,96 @@
# Copyright 2023 Raumschmiede (http://www.raumschmiede.de)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, models
from odoo.exceptions import ValidationError
from odoo.tools import groupby
from .stock_location import NORESTRICTION, SINGLEPACKAGE
class StockMove(models.Model):
_inherit = "stock.move"
def _check_location_package_restriction(self):
"""
Check if the current stock.move can be executed
regarding a potential package restriction
"""
Package = self.env["stock.quant.package"]
error_msgs = []
quants_grouped = self.env["stock.quant"].read_group(
[
("location_id", "in", self.move_line_ids.location_dest_id.ids),
("location_id.package_restriction", "!=", NORESTRICTION),
("quantity", ">", 0),
],
["location_id", "package_id:array_agg"],
"location_id",
)
location_packages = {
g["location_id"][0]: set(g["package_id"]) for g in quants_grouped
}
for location, move_lines in groupby(
self.move_line_ids, lambda m: m.location_dest_id
):
if location.package_restriction == NORESTRICTION:
continue
existing_package_ids = location_packages.get(location.id, set())
existing_packages = Package.browse(existing_package_ids).exists()
new_packages = Package.browse()
for move_line in move_lines:
package = move_line.result_package_id
# CASE: Package restiction exists but there is no package in move
if not package:
error_msgs.append(
_(
"A package is mandatory on the location {location}. "
"You cannot move the product {product} without a package."
).format(
location=location.display_name,
product=move_line.product_id.display_name,
)
)
continue
if location.package_restriction != SINGLEPACKAGE:
continue
# CASE: Package on location, new package is different
if existing_package_ids and package.id not in existing_package_ids:
error_msgs.append(
_(
"Only one package is allowed on the location {location}."
"You cannot add the {package}, there is already "
"{existing_packages}."
).format(
location=location.display_name,
existing_packages=", ".join(
existing_packages.mapped("name")
),
package=package.display_name,
)
)
continue
# CASE: Multiple packages in Move but single location
# with singlepackage Restriction
if new_packages and package not in new_packages:
error_msgs.append(
_(
"Only one package is allowed on the location {location}."
"You cannot move multiple packages to it:"
"{packages}"
).format(
location=location.display_name,
packages=", ".join(new_packages.mapped("display_name")),
)
)
continue
new_packages |= package
if error_msgs:
raise ValidationError("\n".join(error_msgs))
def _action_done(self, cancel_backorder=False):
self._check_location_package_restriction()
return super()._action_done(cancel_backorder=cancel_backorder)

View File

@@ -0,0 +1,3 @@
* Raumschmiede <Raumschmiede.de>
* Jacques-Etienne Baudoux <je@bcim.be>
* Juan Miguel Sánchez Arce <juan.sanchez@camptocamp.com>

View File

@@ -0,0 +1 @@
This module adds the option to set restrictions for the packages that can be put in a location.

View File

@@ -0,0 +1,11 @@
Configure on the location if the contained products must be part of a package
and if you allow multiple package. When the module is installed, by default
there is no restriction on the locations.
The options are:
* Mandatory and unique: The location cannot have products not part of a
package and you cannot have more than 1 package on the location
* Mandatory and not unique: The location cannot have products not part of a
package and you may have store multiple packages on the location
* Not mandatory: The location can contain products not part of a package

View File

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

View File

@@ -0,0 +1,321 @@
# Copyright 2023 Raumschmiede (http://www.raumschmiede.de)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from collections import namedtuple
from odoo.exceptions import ValidationError
from odoo.tests.common import SavepointCase
from odoo.addons.stock_location_package_restriction.models.stock_location import (
MULTIPACKAGE,
NORESTRICTION,
SINGLEPACKAGE,
)
ShortMoveInfo = namedtuple(
"ShortMoveInfo", ["product", "location_dest", "qty", "package_id"]
)
class TestStockMove(SavepointCase):
@classmethod
def setUpClass(cls):
"""
Data:
2 products: product_1, product_2
2 packages: pack_1, pack_2
1 new warehouse: warehouse1
2 new locations: location1 and location2 are children of
warehouse1's stock location and without
restriction
stock:
* 50 product_1 in location_1
* 0 product_2 in stock
"""
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
Product = cls.env["product.product"]
cls.product_1 = Product.create(
{"name": "Wood", "type": "product", "uom_id": cls.uom_unit.id}
)
cls.product_2 = Product.create(
{"name": "Stone", "type": "product", "uom_id": cls.uom_unit.id}
)
# Warehouses
cls.warehouse_1 = cls.env["stock.warehouse"].create(
{
"name": "Base Warehouse",
"reception_steps": "one_step",
"delivery_steps": "ship_only",
"code": "BWH",
}
)
# Locations
cls.location_1 = cls.env["stock.location"].create(
{
"name": "TestLocation1",
"posx": 3,
"location_id": cls.warehouse_1.lot_stock_id.id,
}
)
cls.location_2 = cls.env["stock.location"].create(
{
"name": "TestLocation2",
"posx": 4,
"location_id": cls.warehouse_1.lot_stock_id.id,
}
)
# Packages:
Package = cls.env["stock.quant.package"]
cls.pack_1 = Package.create({"name": "Package 1"})
cls.pack_2 = Package.create({"name": "Package 2"})
cls.supplier_location = cls.env.ref("stock.stock_location_suppliers")
# partner
cls.partner_1 = cls.env["res.partner"].create(
{"name": "Raumschmiede.de", "email": "info@raumschmiede.de"}
)
# picking type
cls.picking_type_in = cls.env.ref("stock.picking_type_in")
cls.StockMove = cls.env["stock.move"]
cls.StockPicking = cls.env["stock.picking"]
@classmethod
def _change_product_qty(cls, product, location, package, qty):
cls.env["stock.quant"].with_context(inventory_mode=True).create(
{
"product_id": product.id,
"package_id": package.id,
"inventory_quantity": qty,
"location_id": location.id,
}
)
def _get_package_in_location(self, location):
return (
self.env["stock.quant"]
.search([("location_id", "=", location.id)])
.mapped("package_id")
)
def _create_and_assign_picking(self, short_move_infos, location_dest=None):
location_dest = location_dest or self.location_1
picking_in = self.StockPicking.create(
{
"partner_id": self.partner_1.id,
"picking_type_id": self.picking_type_in.id,
"location_id": self.supplier_location.id,
"location_dest_id": location_dest.id,
}
)
for move_info in short_move_infos:
self.StockMove.create(
{
"name": move_info.product.name,
"product_id": move_info.product.id,
"product_uom_qty": move_info.qty,
"product_uom": move_info.product.uom_id.id,
"picking_id": picking_in.id,
"location_id": self.supplier_location.id,
"location_dest_id": move_info.location_dest.id,
}
)
picking_in.action_confirm()
for move_info in short_move_infos:
line = picking_in.move_line_ids.filtered(
lambda x: x.product_id.id == move_info.product.id
)
if line:
line.result_package_id = move_info.package_id
return picking_in
def _process_picking(self, picking):
picking.action_assign()
for line in picking.move_line_ids:
line.qty_done = line.product_qty
picking.button_validate()
def test_00(self):
"""
Data:
location_1 without package_restriction
location_1 with 50 product_1 and pack_1
Test case:
Add qty of product_2 into location_1 with pack_2
Expected result:
The location contains the 2 products in 2 different packages
"""
# Inventory Add product_1 to location_1
self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50)
self.assertEqual(self.pack_1, self._get_package_in_location(self.location_1))
self._change_product_qty(self.product_2, self.location_1, self.pack_2, 10)
self.assertEqual(
self.pack_1 | self.pack_2,
self._get_package_in_location(self.location_1),
)
def test_01(self):
"""
Data:
location_1 with single package restriction
location_1 with 50 product_1 and pack_1
Test case:
Add qty of product_2 into location_1 with pack_2
Expected result:
ValidationError
"""
# Inventory Add product_1 to location_1
self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50)
self.assertEqual(self.pack_1, self._get_package_in_location(self.location_1))
self.location_1.package_restriction = SINGLEPACKAGE
with self.assertRaises(ValidationError):
self._change_product_qty(self.product_2, self.location_1, self.pack_2, 10)
def test_02(self):
"""
Data:
location_2 without product nor product restriction
a picking with two move with destination location_2
Test case:
Process the picking
Expected result:
The two packages are on location 2
"""
self.location_2.package_restriction = MULTIPACKAGE
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_1,
location_dest=self.location_2,
qty=2,
package_id=self.pack_1,
),
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_2,
qty=4,
package_id=self.pack_2,
),
],
location_dest=self.location_2,
)
self._process_picking(picking)
self.assertEqual(
self.pack_1 | self.pack_2,
self._get_package_in_location(self.location_2),
)
def test_03(self):
"""
Data:
location_2 without package but with package restriction = 'single package'
a picking with two move with destination location_2
Test case:
Process the picking
Expected result:
ValidationError
"""
self.location_2.package_restriction = SINGLEPACKAGE
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_1,
location_dest=self.location_2,
qty=2,
package_id=self.pack_1,
),
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_2,
qty=2,
package_id=self.pack_2,
),
],
location_dest=self.location_2,
)
with self.assertRaises(ValidationError):
self._process_picking(picking)
def test_04(self):
"""
Data:
location_1 with product_1 and without product restriction = 'single package'
a picking with two moves:
* product_1 -> location_1,
* product_2 -> location_1
Test case:
Process the picking
Expected result:
We now have two products into the same location
"""
# Inventory Add product_1 to location_1
self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50)
self.assertEqual(
self.pack_1,
self._get_package_in_location(self.location_1),
)
self.location_1.package_restriction = NORESTRICTION
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_1,
location_dest=self.location_1,
qty=2,
package_id=self.pack_1,
),
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_1,
qty=2,
package_id=self.pack_2,
),
],
location_dest=self.location_1,
)
self._process_picking(picking)
self.assertEqual(
self.pack_1 | self.pack_2,
self._get_package_in_location(self.location_1),
)
def test_05(self):
"""
Data:
location_1 with product_1 but with product restriction = 'single package'
a picking with one move: product_2 -> location_1
Test case:
Process the picking
Expected result:
ValidationError
"""
# Inventory Add product_1 to location_1
self._change_product_qty(self.product_1, self.location_1, self.pack_1, 50)
self.assertEqual(
self.pack_1,
self._get_package_in_location(self.location_1),
)
self.location_1.package_restriction = SINGLEPACKAGE
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_1,
qty=2,
package_id=self.pack_2,
),
],
location_dest=self.location_1,
)
with self.assertRaises(ValidationError):
self._process_picking(picking)

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="ir.ui.view" id="stock_location_form_view">
<field
name="name"
>stock.location.form (in stock_location_package_restriction)</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock.view_location_form" />
<field name="arch" type="xml">
<xpath expr="//group[last()]">
<group name="restrictions" string="Restrictions">
<field name="package_restriction" />
</group>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="stock_location_tree_view">
<field
name="name"
>stock.location.tree (in stock_location_unique_product)</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock.view_location_tree2" />
<field name="arch" type="xml">
<field name="company_id" position="before">
<field name="package_restriction" />
</field>
</field>
</record>
</odoo>