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/README.rst b/stock_packaging_calculator/README.rst new file mode 100644 index 000000000..adf3a79d7 --- /dev/null +++ b/stock_packaging_calculator/README.rst @@ -0,0 +1,127 @@ +========================== +Stock packaging calculator +========================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/15.0/stock_packaging_calculator + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-15-0/stock-logistics-warehouse-15-0-stock_packaging_calculator + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/153/15.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Basic module providing an helper method to calculate the quantity of product by packaging. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +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: + + .. code-block:: + + >>> product.product_qty_by_packaging(2860) + + [ + {"id": 1, "qty": 2, "name": "Pallet"}, + {"id": 2, "qty": 1, "name": "Big box"}, + {"id": 3, "qty": 7, "name": "Box"}, + {"id": 100, "qty": 10, "name": "Units"}, + ] + +With this you can show a proper message to warehouse operators to quickly pick the quantity they need. + +Optionally you can get contained packaging by passing `with_contained` flag: + + + .. code-block:: + + >>> product.product_qty_by_packaging(2860, with_contained=True) + + [ + {"id": 1, "qty": 2, "name": "Pallet", "contained": [{"id": 2, "qty": 2, "name": "Big box"}]}, + {"id": 2, "qty": 1, "name": "Big box", "contained": [{"id": 3, "qty": 10, "name": "Box"}]}, + {"id": 3, "qty": 7, "name": "Box", "contained": [{"id": 100, "qty": 50, "name": "Units"}]}, + {"id": 100, "qty": 10, "name": "Units", "contained": []},}, + ] + +Known issues / Roadmap +====================== + +TODO + +1. Fractional quantities (eg: 0.5 Kg) are lost when counting units +2. Maybe rely on `packaging_uom` + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi +* Christopher Ormaza + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. 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..b7e7dfee9 --- /dev/null +++ b/stock_packaging_calculator/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) +{ + "name": "Stock packaging calculator", + "summary": "Compute product quantity to pick by packaging", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Warehouse Management", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": ["product"], +} diff --git a/stock_packaging_calculator/i18n/stock_packaging_calculator.pot b/stock_packaging_calculator/i18n/stock_packaging_calculator.pot new file mode 100644 index 000000000..3a5552568 --- /dev/null +++ b/stock_packaging_calculator/i18n/stock_packaging_calculator.pot @@ -0,0 +1,39 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_packaging_calculator +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_packaging_calculator +#: model:ir.model.fields,field_description:stock_packaging_calculator.field_product_product__packaging_contained_mapping +msgid "Packaging Contained Mapping" +msgstr "" + +#. module: stock_packaging_calculator +#: model:ir.model,name:stock_packaging_calculator.model_product_product +msgid "Product" +msgstr "" + +#. module: stock_packaging_calculator +#: model:ir.model,name:stock_packaging_calculator.model_product_qty_by_packaging_mixin +msgid "Product Qty By Packaging (Mixin)" +msgstr "" + +#. module: stock_packaging_calculator +#: model:ir.model.fields,field_description:stock_packaging_calculator.field_product_qty_by_packaging_mixin__product_qty_by_packaging_display +msgid "Qty by packaging" +msgstr "" + +#. module: stock_packaging_calculator +#: model:ir.model.fields,help:stock_packaging_calculator.field_product_product__packaging_contained_mapping +msgid "Technical field to store contained packaging. " +msgstr "" diff --git a/stock_packaging_calculator/models/__init__.py b/stock_packaging_calculator/models/__init__.py new file mode 100644 index 000000000..fb73bb4d1 --- /dev/null +++ b/stock_packaging_calculator/models/__init__.py @@ -0,0 +1,2 @@ +from . import product +from . import product_qty_by_packaging_mixin diff --git a/stock_packaging_calculator/models/product.py b/stock_packaging_calculator/models/product.py new file mode 100644 index 000000000..5f9342424 --- /dev/null +++ b/stock_packaging_calculator/models/product.py @@ -0,0 +1,213 @@ +# Copyright 2020 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) + +import unicodedata +from collections import namedtuple + +from odoo import api, models +from odoo.tools import float_compare + +from odoo.addons.base_sparse_field.models.fields import Serialized + +# Unify records as we mix up w/ UoM +Packaging = namedtuple("Packaging", "id name qty barcode is_unit") + +NO_BREAK_SPACE_CHAR = unicodedata.lookup("NO-BREAK SPACE") + + +class Product(models.Model): + _inherit = "product.product" + + packaging_contained_mapping = Serialized( + compute="_compute_packaging_contained_mapping", + help="Technical field to store contained packaging. ", + ) + + @api.depends_context("lang") + @api.depends("packaging_ids.qty") + def _compute_packaging_contained_mapping(self): + for rec in self: + rec.packaging_contained_mapping = rec._packaging_contained_mapping() + + def _packaging_contained_mapping(self): + """Produce a mapping of packaging and contained packagings. + + Used mainly for `product_qty_by_packaging` but can be used + to display info as you prefer. + + :returns: a dictionary in the form {pkg.id: [contained packages]} + """ + res = {} + packaging = self._ordered_packaging() + for i, pkg in enumerate(packaging): + if pkg.is_unit: + # skip minimal unit + continue + res[pkg.id] = self._product_qty_by_packaging(packaging[i + 1 :], pkg.qty) + return res + + def product_qty_by_packaging(self, prod_qty, with_contained=False): + """Calculate quantity by packaging. + + The minimal quantity is always represented by the UoM of the product. + + Limitation: fractional quantities are lost. + + :prod_qty: total qty to satisfy. + :with_contained: include calculation of contained packagings. + + eg: 1 pallet contains 4 big boxes and 6 little boxes. + + :returns: list of dict in the form + + [{id: 1, qty: qty_per_package, name: package_name}] + + If `with_contained` is passed, each element will include + the quantity of smaller packaging, like: + + {contained: [{id: 1, qty: 4, name: "Big box"}]} + """ + self.ensure_one() + return self._product_qty_by_packaging( + self._ordered_packaging(), + prod_qty, + with_contained=with_contained, + ) + + def _ordered_packaging(self): + """Prepare packaging ordered by qty and exclude empty ones. + + Use ctx key `_packaging_filter` to pass a function to filter packaging + to be considered. + + Use ctx key `_packaging_name_getter` to pass a function to change + the display name of the packaging. + """ + custom_filter = self.env.context.get("_packaging_filter", lambda x: x) + name_getter = self.env.context.get( + "_packaging_name_getter", self._packaging_name_getter + ) + packagings = sorted( + ( + Packaging(x.id, name_getter(x), x.qty, x.barcode, False) + for x in self.packaging_ids.filtered(custom_filter) + # Exclude the ones w/ zero qty as they are useless for the math + if x.qty + ), + reverse=True, + key=lambda x: x.qty, + ) + # Add minimal unit + packagings.append( + # NOTE: the ID here could clash w/ one of the packaging's. + # If you create a mapping based on IDs, keep this in mind. + # You can use `is_unit` to check this. + Packaging(self.uom_id.id, self.uom_id.name, self.uom_id.factor, None, True) + ) + return packagings + + def _packaging_name_getter(self, packaging): + return packaging.name + + def _product_qty_by_packaging(self, pkg_by_qty, qty, with_contained=False): + """Produce a list of dictionaries of packaging info.""" + # TODO: refactor to handle fractional quantities (eg: 0.5 Kg) + res = [] + prepare_values = self.env.context.get( + "_packaging_values_handler", self._prepare_qty_by_packaging_values + ) + for pkg in pkg_by_qty: + qty_per_pkg, qty = self._qty_by_pkg(pkg.qty, qty) + if qty_per_pkg: + value = prepare_values(pkg, qty_per_pkg) + if with_contained: + contained = None + if not pkg.is_unit: + mapping = self.packaging_contained_mapping + # integer keys are serialized as strings :/ + contained = mapping.get(str(pkg.id)) + value["contained"] = contained + res.append(value) + 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=self.uom_id.rounding) + >= 0.0 + ): + qty -= pkg_qty + qty_per_pkg += 1 + return qty_per_pkg, qty + + def _prepare_qty_by_packaging_values(self, packaging, qty_per_pkg): + return { + "id": packaging.id, + "qty": qty_per_pkg, + "name": packaging.name, + "is_unit": packaging.is_unit, + "barcode": packaging.barcode, + } + + def product_qty_by_packaging_as_str( + self, prod_qty, include_total_units=False, only_packaging=False + ): + """Return a string representing the qty of each packaging. + + :param prod_qty: the qty of current product to translate to pkg qty + :param include_total_units: includes total qty required initially + :param only_packaging: exclude units if you have only units. + IOW: if the qty does not match any packaging and this flag is true + you'll get an empty string instead of `N units`. + """ + self.ensure_one() + if not prod_qty: + return "" + + qty_by_packaging = self.product_qty_by_packaging(prod_qty) + if not qty_by_packaging: + return "" + + # Exclude unit qty and reuse it later + unit_qty = None + has_only_units = True + _qty_by_packaging = [] + for pkg_qty in qty_by_packaging: + if pkg_qty["is_unit"]: + unit_qty = pkg_qty["qty"] + continue + has_only_units = False + _qty_by_packaging.append(pkg_qty) + # Browse them all at once + records = self.env["product.packaging"].browse( + [x["id"] for x in _qty_by_packaging] + ) + _qty_by_packaging_as_str = self.env.context.get( + "_qty_by_packaging_as_str", self._qty_by_packaging_as_str + ) + # Collect all strings representations + as_string = [] + for record, info in zip(records, _qty_by_packaging): + bit = _qty_by_packaging_as_str(record, info["qty"]) + if bit: + as_string.append(bit) + # Restore unit information if any. + include_units = (has_only_units and not only_packaging) or not has_only_units + if unit_qty and include_units: + as_string.append(f"{unit_qty} {self.uom_id.name}") + # We want to avoid line break here as this string + # can be used by reports + res = f",{NO_BREAK_SPACE_CHAR}".join(as_string) + if include_total_units and not has_only_units: + res += " " + self._qty_by_packaging_total_units(prod_qty) + return res + + def _qty_by_packaging_as_str(self, packaging, qty): + return f"{qty} {packaging.name}" + + def _qty_by_packaging_total_units(self, prod_qty): + return f"({prod_qty} {self.uom_id.name})" diff --git a/stock_packaging_calculator/models/product_qty_by_packaging_mixin.py b/stock_packaging_calculator/models/product_qty_by_packaging_mixin.py new file mode 100644 index 000000000..693ad561a --- /dev/null +++ b/stock_packaging_calculator/models/product_qty_by_packaging_mixin.py @@ -0,0 +1,49 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# @author: Sébastien Alix +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) +from odoo import api, fields, models + + +class ProductQtyByPackagingMixin(models.AbstractModel): + """Allow displaying product qty by packaging.""" + + _name = "product.qty_by_packaging.mixin" + _description = "Product Qty By Packaging (Mixin)" + + _qty_by_pkg__product_field_name = "product_id" + _qty_by_pkg__qty_field_name = None + + product_qty_by_packaging_display = fields.Char( + compute="_compute_product_qty_by_packaging_display", string="Qty by packaging" + ) + + def _product_qty_by_packaging_display_depends(self): + depends = [] + if self._qty_by_pkg__product_field_name: + depends.append(self._qty_by_pkg__product_field_name) + if self._qty_by_pkg__qty_field_name: + depends.append(self._qty_by_pkg__qty_field_name) + return depends + + @api.depends_context("lang", "qty_by_pkg_total_units", "qty_by_pkg_only_packaging") + @api.depends(lambda self: self._product_qty_by_packaging_display_depends()) + def _compute_product_qty_by_packaging_display(self): + include_total_units = self.env.context.get("qty_by_pkg_total_units", False) + only_packaging = self.env.context.get("qty_by_pkg_only_packaging", False) + for record in self: + value = "" + product = record._qty_by_packaging_get_product() + if product: + value = product.product_qty_by_packaging_as_str( + record._qty_by_packaging_get_qty(), + include_total_units=include_total_units, + only_packaging=only_packaging, + ) + record.product_qty_by_packaging_display = value + + def _qty_by_packaging_get_product(self): + return self[self._qty_by_pkg__product_field_name] + + def _qty_by_packaging_get_qty(self): + return self[self._qty_by_pkg__qty_field_name] diff --git a/stock_packaging_calculator/readme/CONTRIBUTORS.rst b/stock_packaging_calculator/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..5daf85c2e --- /dev/null +++ b/stock_packaging_calculator/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Christopher Ormaza diff --git a/stock_packaging_calculator/readme/DESCRIPTION.rst b/stock_packaging_calculator/readme/DESCRIPTION.rst new file mode 100644 index 000000000..1d6fceb5c --- /dev/null +++ b/stock_packaging_calculator/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Basic module providing an helper method to calculate the quantity of product by packaging. 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/readme/USAGE.rst b/stock_packaging_calculator/readme/USAGE.rst new file mode 100644 index 000000000..73f05a708 --- /dev/null +++ b/stock_packaging_calculator/readme/USAGE.rst @@ -0,0 +1,36 @@ +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: + + .. code-block:: + + >>> product.product_qty_by_packaging(2860) + + [ + {"id": 1, "qty": 2, "name": "Pallet"}, + {"id": 2, "qty": 1, "name": "Big box"}, + {"id": 3, "qty": 7, "name": "Box"}, + {"id": 100, "qty": 10, "name": "Units"}, + ] + +With this you can show a proper message to warehouse operators to quickly pick the quantity they need. + +Optionally you can get contained packaging by passing `with_contained` flag: + + + .. code-block:: + + >>> product.product_qty_by_packaging(2860, with_contained=True) + + [ + {"id": 1, "qty": 2, "name": "Pallet", "contained": [{"id": 2, "qty": 2, "name": "Big box"}]}, + {"id": 2, "qty": 1, "name": "Big box", "contained": [{"id": 3, "qty": 10, "name": "Box"}]}, + {"id": 3, "qty": 7, "name": "Box", "contained": [{"id": 100, "qty": 50, "name": "Units"}]}, + {"id": 100, "qty": 10, "name": "Units", "contained": []},}, + ] diff --git a/stock_packaging_calculator/static/description/icon.png b/stock_packaging_calculator/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/stock_packaging_calculator/static/description/icon.png differ diff --git a/stock_packaging_calculator/static/description/index.html b/stock_packaging_calculator/static/description/index.html new file mode 100644 index 000000000..0745b2c6d --- /dev/null +++ b/stock_packaging_calculator/static/description/index.html @@ -0,0 +1,473 @@ + + + + + + +Stock packaging calculator + + + +
+

Stock packaging calculator

+ + +

Alpha License: LGPL-3 OCA/stock-logistics-warehouse Translate me on Weblate Try me on Runbot

+

Basic module providing an helper method to calculate the quantity of product by packaging.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

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)
+
+[
+    {"id": 1, "qty": 2, "name": "Pallet"},
+    {"id": 2, "qty": 1, "name": "Big box"},
+    {"id": 3, "qty": 7, "name": "Box"},
+    {"id": 100, "qty": 10, "name": "Units"},
+]
+
+
+

With this you can show a proper message to warehouse operators to quickly pick the quantity they need.

+

Optionally you can get contained packaging by passing with_contained flag:

+
+
+>>> product.product_qty_by_packaging(2860, with_contained=True)
+
+[
+    {"id": 1, "qty": 2, "name": "Pallet", "contained": [{"id": 2, "qty": 2, "name": "Big box"}]},
+    {"id": 2, "qty": 1, "name": "Big box", "contained": [{"id": 3, "qty": 10, "name": "Box"}]},
+    {"id": 3, "qty": 7, "name": "Box", "contained": [{"id": 100, "qty": 50, "name": "Units"}]},
+    {"id": 100, "qty": 10, "name": "Units", "contained": []},},
+]
+
+
+
+
+

Known issues / Roadmap

+

TODO

+
    +
  1. Fractional quantities (eg: 0.5 Kg) are lost when counting units
  2. +
  3. Maybe rely on packaging_uom
  4. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/stock-logistics-warehouse project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_packaging_calculator/tests/__init__.py b/stock_packaging_calculator/tests/__init__.py new file mode 100644 index 000000000..441177cdc --- /dev/null +++ b/stock_packaging_calculator/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_packaging_calc +from . import test_pkg_qty_str diff --git a/stock_packaging_calculator/tests/common.py b/stock_packaging_calculator/tests/common.py new file mode 100644 index 000000000..4aa0579a5 --- /dev/null +++ b/stock_packaging_calculator/tests/common.py @@ -0,0 +1,42 @@ +# Copyright 2020 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) +from odoo.tests import TransactionCase + + +class TestCommon(TransactionCase): + + at_install = False + post_install = True + maxDiff = None + + @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.product_a = cls.env["product.product"].create( + { + "name": "Product A", + "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, "barcode": "BOX"} + ) + cls.pkg_big_box = cls.env["product.packaging"].create( + { + "name": "Big Box", + "product_id": cls.product_a.id, + "qty": 200, + "barcode": "BIGBOX", + } + ) + cls.pkg_pallet = cls.env["product.packaging"].create( + { + "name": "Pallet", + "product_id": cls.product_a.id, + "qty": 2000, + "barcode": "PALLET", + } + ) 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..3d4bbfa85 --- /dev/null +++ b/stock_packaging_calculator/tests/test_packaging_calc.py @@ -0,0 +1,162 @@ +# Copyright 2020 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) +from .common import TestCommon +from .utils import make_pkg_values + + +class TestCalc(TestCommon): + def test_contained_mapping(self): + self.assertEqual( + self.product_a.packaging_contained_mapping, + { + str(self.pkg_pallet.id): [make_pkg_values(self.pkg_big_box, qty=10)], + str(self.pkg_big_box.id): [make_pkg_values(self.pkg_box, qty=4)], + str(self.pkg_box.id): [make_pkg_values(self.uom_unit, qty=50)], + }, + ) + # Update pkg qty + self.pkg_pallet.qty = 4000 + self.assertEqual( + self.product_a.packaging_contained_mapping, + { + str(self.pkg_pallet.id): [make_pkg_values(self.pkg_big_box, qty=20)], + str(self.pkg_big_box.id): [make_pkg_values(self.pkg_box, qty=4)], + str(self.pkg_box.id): [make_pkg_values(self.uom_unit, qty=50)], + }, + ) + + def test_calc_1(self): + """Test easy behavior 1.""" + expected = [ + make_pkg_values(self.pkg_pallet, qty=1), + make_pkg_values(self.pkg_big_box, qty=3), + make_pkg_values(self.pkg_box, qty=1), + make_pkg_values(self.uom_unit, qty=5), + ] + self.assertEqual(self.product_a.product_qty_by_packaging(2655), expected) + + def test_calc_2(self): + """Test easy behavior 2.""" + expected = [ + make_pkg_values(self.pkg_big_box, qty=1), + make_pkg_values(self.pkg_box, qty=3), + ] + self.assertEqual(self.product_a.product_qty_by_packaging(350), expected) + + def test_calc_3(self): + """Test easy behavior 3.""" + expected = [ + make_pkg_values(self.pkg_box, qty=1), + make_pkg_values(self.uom_unit, qty=30), + ] + self.assertEqual(self.product_a.product_qty_by_packaging(80), expected) + + def test_calc_6(self): + """Test fractional qty is lost.""" + expected = [ + make_pkg_values(self.pkg_box, qty=1), + ] + self.assertEqual(self.product_a.product_qty_by_packaging(50.5), expected) + + def test_calc_filter(self): + """Test packaging filter.""" + expected = [ + make_pkg_values(self.pkg_big_box, qty=13), + make_pkg_values(self.pkg_box, qty=1), + make_pkg_values(self.uom_unit, qty=5), + ] + self.assertEqual( + self.product_a.with_context( + _packaging_filter=lambda x: x != self.pkg_pallet + ).product_qty_by_packaging(2655), + expected, + ) + + def test_calc_name_get(self): + """Test custom name getter.""" + expected = [ + make_pkg_values(self.pkg_pallet, qty=1, name="FOO " + self.pkg_pallet.name), + make_pkg_values( + self.pkg_big_box, qty=3, name="FOO " + self.pkg_big_box.name + ), + make_pkg_values(self.pkg_box, qty=1, name="FOO " + self.pkg_box.name), + make_pkg_values(self.uom_unit, qty=5, name=self.uom_unit.name), + ] + self.assertEqual( + self.product_a.with_context( + _packaging_name_getter=lambda x: "FOO " + x.name + ).product_qty_by_packaging(2655), + expected, + ) + + def test_calc_custom_values(self): + """Test custom values handler.""" + expected = [ + {"my_qty": 1, "foo": self.pkg_pallet.name}, + {"my_qty": 3, "foo": self.pkg_big_box.name}, + {"my_qty": 1, "foo": self.pkg_box.name}, + {"my_qty": 5, "foo": self.uom_unit.name}, + ] + self.assertEqual( + self.product_a.with_context( + _packaging_values_handler=lambda pkg, qty_per_pkg: { + "my_qty": qty_per_pkg, + "foo": pkg.name, + } + ).product_qty_by_packaging(2655), + expected, + ) + + def test_calc_sub1(self): + """Test contained packaging behavior 1.""" + expected = [ + make_pkg_values( + self.pkg_pallet, + qty=1, + contained=[make_pkg_values(self.pkg_big_box, qty=10)], + ), + make_pkg_values( + self.pkg_big_box, + qty=3, + contained=[make_pkg_values(self.pkg_box, qty=4)], + ), + make_pkg_values( + self.pkg_box, + qty=1, + contained=[make_pkg_values(self.uom_unit, qty=50)], + ), + make_pkg_values(self.uom_unit, qty=5, contained=None), + ] + self.assertEqual( + self.product_a.product_qty_by_packaging(2655, with_contained=True), + expected, + ) + + def test_calc_sub2(self): + """Test contained packaging behavior 2.""" + self.pkg_box.qty = 30 + expected = [ + make_pkg_values( + self.pkg_pallet, + qty=1, + contained=[make_pkg_values(self.pkg_big_box, qty=10)], + ), + make_pkg_values( + self.pkg_big_box, + qty=3, + contained=[ + make_pkg_values(self.pkg_box, qty=6), + make_pkg_values(self.uom_unit, qty=20), + ], + ), + make_pkg_values( + self.pkg_box, + qty=1, + contained=[make_pkg_values(self.uom_unit, qty=30)], + ), + make_pkg_values(self.uom_unit, qty=25, contained=None), + ] + self.assertEqual( + self.product_a.product_qty_by_packaging(2655, with_contained=True), + expected, + ) diff --git a/stock_packaging_calculator/tests/test_pkg_qty_str.py b/stock_packaging_calculator/tests/test_pkg_qty_str.py new file mode 100644 index 000000000..7fca487de --- /dev/null +++ b/stock_packaging_calculator/tests/test_pkg_qty_str.py @@ -0,0 +1,65 @@ +# Copyright 2021 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) +from .common import TestCommon + + +class TestAsStr(TestCommon): + def test_as_str(self): + self.assertEqual(self.product_a.product_qty_by_packaging_as_str(10), "10 Units") + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str(10, only_packaging=True), "" + ) + self.assertEqual(self.product_a.product_qty_by_packaging_as_str(100), "2 Box") + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str(250), "1 Big Box,\xa01 Box" + ) + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str(255), + "1 Big Box,\xa01 Box,\xa05 Units", + ) + # only_packaging has no impact if we get not only units + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str(255, only_packaging=True), + "1 Big Box,\xa01 Box,\xa05 Units", + ) + + def test_as_str_w_units(self): + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str( + 10, include_total_units=True + ), + "10 Units", + ) + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str( + 100, include_total_units=True + ), + "2 Box (100 Units)", + ) + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str( + 250, include_total_units=True + ), + "1 Big Box,\xa01 Box (250 Units)", + ) + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str( + 255, include_total_units=True + ), + "1 Big Box,\xa01 Box,\xa05 Units (255 Units)", + ) + # only_packaging has no impact if we get not only units + self.assertEqual( + self.product_a.product_qty_by_packaging_as_str( + 255, include_total_units=True, only_packaging=True + ), + "1 Big Box,\xa01 Box,\xa05 Units (255 Units)", + ) + + def test_as_str_custom_name(self): + self.assertEqual( + self.product_a.with_context( + _qty_by_packaging_as_str=lambda pkg, qty: f"{pkg.name} {qty} FOO" + ).product_qty_by_packaging_as_str(250), + "Big Box 1 FOO,\xa0Box 1 FOO", + ) diff --git a/stock_packaging_calculator/tests/utils.py b/stock_packaging_calculator/tests/utils.py new file mode 100644 index 000000000..26c4b357b --- /dev/null +++ b/stock_packaging_calculator/tests/utils.py @@ -0,0 +1,26 @@ +# Copyright 2021 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl) + + +def make_pkg_values(record, **kw): + """Helper to generate test values for packaging.""" + name = record.name + if record._name == "uom.uom": + is_unit = True + barcode = None + qty = record.factor + elif record._name == "product.packaging": + qty = record.qty + is_unit = False + barcode = record.barcode + if record.product_id: + name = record.product_id._packaging_name_getter(record) + values = { + "id": record.id, + "name": name, + "qty": qty, + "barcode": barcode, + "is_unit": is_unit, + } + values.update(kw) + return values