diff --git a/setup/stock_packaging_calculator/odoo/addons/stock_packaging_calculator b/setup/stock_packaging_calculator/odoo/addons/stock_packaging_calculator new file mode 120000 index 000000000..d3e128c8d --- /dev/null +++ b/setup/stock_packaging_calculator/odoo/addons/stock_packaging_calculator @@ -0,0 +1 @@ +../../../../stock_packaging_calculator \ No newline at end of file diff --git a/setup/stock_packaging_calculator/setup.py b/setup/stock_packaging_calculator/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_packaging_calculator/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_packaging_calculator/__init__.py b/stock_packaging_calculator/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_packaging_calculator/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_packaging_calculator/__manifest__.py b/stock_packaging_calculator/__manifest__.py new file mode 100644 index 000000000..4399bba90 --- /dev/null +++ b/stock_packaging_calculator/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "Stock packaging calculator", + "summary": "Compute product quantity to pick by packaging", + "version": "13.0.1.0.0", + "development_status": "Alpha", + "category": "Warehouse Management", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": ["product"], +} diff --git a/stock_packaging_calculator/models/__init__.py b/stock_packaging_calculator/models/__init__.py new file mode 100644 index 000000000..9649db77a --- /dev/null +++ b/stock_packaging_calculator/models/__init__.py @@ -0,0 +1 @@ +from . import product diff --git a/stock_packaging_calculator/models/product.py b/stock_packaging_calculator/models/product.py new file mode 100644 index 000000000..5dd73bac3 --- /dev/null +++ b/stock_packaging_calculator/models/product.py @@ -0,0 +1,51 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import models +from odoo.tools import float_compare + + +class Product(models.Model): + _inherit = "product.product" + + def product_qty_by_packaging(self, prod_qty, min_unit=None): + """Calculate quantity by packaging. + + Limitation: fractional quantities are lost. + + :prod_qty: total qty to satisfy. + :min_unit: minimal unit of measure as a tuple (qty, name). + Default: to UoM unit. + :returns: list of tuple in the form [(qty_per_package, package_name)] + + """ + packagings = [(x.qty, x.name) for x in self.packaging_ids] + if min_unit is None: + # You can pass `False` to skip it. + single_unit = self.uom_id + min_unit = (single_unit.factor, single_unit.name) + if min_unit: + packagings.append(min_unit) + return self._product_qty_by_packaging( + sorted(packagings, reverse=True), prod_qty + ) + + def _product_qty_by_packaging(self, pkg_by_qty, qty): + """Produce a list of tuple of packaging qty and packaging name.""" + # TODO: refactor to handle fractional quantities (eg: 0.5 Kg) + res = [] + for pkg_qty, pkg in pkg_by_qty: + qty_per_pkg, qty = self._qty_by_pkg(pkg_qty, qty) + if qty_per_pkg: + res.append((qty_per_pkg, pkg)) + if not qty: + break + return res + + def _qty_by_pkg(self, pkg_qty, qty): + """Calculate qty needed for given package qty.""" + qty_per_pkg = 0 + while float_compare(qty - pkg_qty, 0.0, precision_digits=3) >= 0.0: + qty -= pkg_qty + qty_per_pkg += 1 + return qty_per_pkg, qty diff --git a/stock_packaging_calculator/readme/CONTRIBUTORS.rst b/stock_packaging_calculator/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..f583948be --- /dev/null +++ b/stock_packaging_calculator/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Simone Orsi diff --git a/stock_packaging_calculator/readme/DESCRIPTION.rst b/stock_packaging_calculator/readme/DESCRIPTION.rst new file mode 100644 index 000000000..b470f8a94 --- /dev/null +++ b/stock_packaging_calculator/readme/DESCRIPTION.rst @@ -0,0 +1,18 @@ + +Basic module providing an helper method to calculate the quantity of product by packaging. + +Imagine you have the following packagings: + +* Pallet: 1000 Units +* Big box: 500 Units +* Box: 50 Units + +and you have to pick from your warehouse 2860 Units. + +Then you can do: + + >>> product.product_qty_by_packaging(2860) + + [(2, "Pallet"), (1, "Big Box"), (7, "Box"), (10, "Units")] + +With this you can show a proper message to warehouse operators to quickly pick the quantity they need. diff --git a/stock_packaging_calculator/readme/ROADMAP.rst b/stock_packaging_calculator/readme/ROADMAP.rst new file mode 100644 index 000000000..e4881d5e6 --- /dev/null +++ b/stock_packaging_calculator/readme/ROADMAP.rst @@ -0,0 +1,4 @@ +TODO + +1. Fractional quantities (eg: 0.5 Kg) are lost when counting units +2. Maybe rely on `packaging_uom` diff --git a/stock_packaging_calculator/tests/__init__.py b/stock_packaging_calculator/tests/__init__.py new file mode 100644 index 000000000..04c47e711 --- /dev/null +++ b/stock_packaging_calculator/tests/__init__.py @@ -0,0 +1 @@ +from . import test_packaging_calc diff --git a/stock_packaging_calculator/tests/test_packaging_calc.py b/stock_packaging_calculator/tests/test_packaging_calc.py new file mode 100644 index 000000000..c5836b941 --- /dev/null +++ b/stock_packaging_calculator/tests/test_packaging_calc.py @@ -0,0 +1,66 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo.tests import SavepointCase + + +class TestCalc(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_kg = cls.env.ref("uom.product_uom_kgm") + cls.product_a = cls.env["product.product"].create( + { + "name": "Product A", + "type": "product", + "uom_id": cls.uom_unit.id, + "uom_po_id": cls.uom_unit.id, + } + ) + cls.pkg_box = cls.env["product.packaging"].create( + {"name": "Box", "product_id": cls.product_a.id, "qty": 50} + ) + cls.pkg_big_box = cls.env["product.packaging"].create( + {"name": "Big Box", "product_id": cls.product_a.id, "qty": 200} + ) + cls.pkg_pallet = cls.env["product.packaging"].create( + {"name": "Pallet", "product_id": cls.product_a.id, "qty": 2000} + ) + + def test_calc_1(self): + """Test easy behavior 1.""" + self.assertEqual( + self.product_a.product_qty_by_packaging(2655), + [(1, "Pallet"), (3, "Big Box"), (1, "Box"), (5, self.uom_unit.name)], + ) + + def test_calc_2(self): + """Test easy behavior 2.""" + self.assertEqual( + self.product_a.product_qty_by_packaging(350), [(1, "Big Box"), (3, "Box")] + ) + + def test_calc_3(self): + """Test easy behavior 3.""" + self.assertEqual( + self.product_a.product_qty_by_packaging(80), + [(1, "Box"), (30, self.uom_unit.name)], + ) + + def test_calc_4(self): + """Test minimal unit override.""" + self.assertEqual( + self.product_a.product_qty_by_packaging(80, min_unit=(5, "Pack 5")), + [(1, "Box"), (6, "Pack 5")], + ) + + def test_calc_5(self): + """Test no minimal unit.""" + self.assertEqual( + self.product_a.product_qty_by_packaging(80, min_unit=False), [(1, "Box")] + ) + + def test_calc_6(self): + """Test fractional qty is lost.""" + self.assertEqual(self.product_a.product_qty_by_packaging(50.5), [(1, "Box")])