diff --git a/mrp_package_propagation/README.rst b/mrp_package_propagation/README.rst new file mode 100644 index 000000000..d8cf16961 --- /dev/null +++ b/mrp_package_propagation/README.rst @@ -0,0 +1,94 @@ +======================= +MRP Package Propagation +======================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmanufacture-lightgray.png?logo=github + :target: https://github.com/OCA/manufacture/tree/14.0/mrp_package_propagation + :alt: OCA/manufacture +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/manufacture-14-0/manufacture-14-0-mrp_package_propagation + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/129/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allow to propagate a package from a component to a finished product. + +.. 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 +===== + +On the BoM: + +* enable the option "Package Propagation" +* flag one of the BoM line with "Propagate Package" + +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 +~~~~~~~~~~~~ + +* Sébastien Alix + +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. + +.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix + +Current `maintainer `__: + +|maintainer-sebalix| + +This module is part of the `OCA/manufacture `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mrp_package_propagation/__init__.py b/mrp_package_propagation/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/mrp_package_propagation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mrp_package_propagation/__manifest__.py b/mrp_package_propagation/__manifest__.py new file mode 100644 index 000000000..db939349f --- /dev/null +++ b/mrp_package_propagation/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "MRP Package Propagation", + "version": "14.0.1.0.0", + "development_status": "Alpha", + "license": "AGPL-3", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["sebalix"], + "summary": "Propagate a package from a component to a finished product", + "website": "https://github.com/OCA/manufacture", + "category": "Manufacturing", + "depends": ["mrp"], + "data": [ + "views/mrp_bom.xml", + "views/mrp_production.xml", + ], + "installable": True, + "application": False, +} diff --git a/mrp_package_propagation/models/__init__.py b/mrp_package_propagation/models/__init__.py new file mode 100644 index 000000000..6028eeffd --- /dev/null +++ b/mrp_package_propagation/models/__init__.py @@ -0,0 +1,4 @@ +from . import mrp_bom +from . import mrp_bom_line +from . import mrp_production +from . import stock_move diff --git a/mrp_package_propagation/models/mrp_bom.py b/mrp_package_propagation/models/mrp_bom.py new file mode 100644 index 000000000..2183541b6 --- /dev/null +++ b/mrp_package_propagation/models/mrp_bom.py @@ -0,0 +1,61 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class MrpBom(models.Model): + _inherit = "mrp.bom" + + package_propagation = fields.Boolean( + default=False, + help=( + "Allow to propagate the package " + "from a component to the finished product." + ), + ) + display_package_propagation = fields.Boolean( + compute="_compute_display_package_propagation" + ) + + @api.depends( + "type", + "product_tmpl_id.tracking", + "product_qty", + "product_uom_id", + "bom_line_ids.product_id.tracking", + "bom_line_ids.product_qty", + "bom_line_ids.product_uom_id", + ) + def _compute_display_package_propagation(self): + """Check if a package can be propagated. + + A package can be propagated from a component to the finished product if + the type of the BoM is normal (Manufacture this product) + """ + for bom in self: + bom.display_package_propagation = ( + bom.type in self._get_package_propagation_bom_types() + ) + + def _get_package_propagation_bom_types(self): + return ["normal"] + + @api.onchange("display_package_propagation") + def onchange_display_package_propagation(self): + if not self.display_package_propagation: + self.package_propagation = False + + @api.constrains("package_propagation") + def _check_propagate_package(self): + for bom in self: + if not bom.package_propagation: + continue + if not bom.bom_line_ids.filtered("propagate_package"): + raise ValidationError( + _( + "With 'Package Propagation' enabled, a line has " + "to be configured with the 'Propagate Package' option." + ) + ) diff --git a/mrp_package_propagation/models/mrp_bom_line.py b/mrp_package_propagation/models/mrp_bom_line.py new file mode 100644 index 000000000..294408029 --- /dev/null +++ b/mrp_package_propagation/models/mrp_bom_line.py @@ -0,0 +1,64 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + + +class MrpBomLine(models.Model): + _inherit = "mrp.bom.line" + + propagate_package = fields.Boolean( + default=False, + ) + display_propagate_package = fields.Boolean( + compute="_compute_display_propagate_package" + ) + + @api.depends( + "bom_id.display_package_propagation", + "bom_id.package_propagation", + ) + def _compute_display_propagate_package(self): + for line in self: + line.display_propagate_package = ( + line.bom_id.display_package_propagation + and line.bom_id.package_propagation + ) + + @api.constrains("propagate_package") + def _check_propagate_package(self): + """ + This function should check: + + - if the bom has package_propagation marked, there is one and + only one line of this bom with `propagate_package` marked. + - if the component qty is 1 unit + """ + uom_unit = self.env.ref("uom.product_uom_unit") + for line in self: + if not line.bom_id.package_propagation: + continue + lines_to_propagate = line.bom_id.bom_line_ids.filtered( + lambda o: o.propagate_package + ) + if len(lines_to_propagate) > 1: + raise ValidationError( + _( + "Only one component can propagate its package " + "to the finished product." + ) + ) + qty_ok = ( + tools.float_compare( + line.product_qty, 1, precision_rounding=uom_unit.rounding + ) + == 0 + ) + if line.propagate_package and ( + line.product_uom_id != uom_unit or not qty_ok + ): + raise ValidationError( + _("The component propagating the package must consume 1 %s.") + % uom_unit.display_name + ) diff --git a/mrp_package_propagation/models/mrp_production.py b/mrp_package_propagation/models/mrp_production.py new file mode 100644 index 000000000..8de9ada67 --- /dev/null +++ b/mrp_package_propagation/models/mrp_production.py @@ -0,0 +1,105 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + is_package_propagated = fields.Boolean( + default=False, + readonly=True, + string="Is package propagated?", + help="Package is propagated from a component to the finished product.", + ) + propagated_package_id = fields.Many2one( + comodel_name="stock.quant.package", + compute="_compute_propagated_package_id", + string="Propagated package", + help=( + "The BoM used on this manufacturing order is set to propagate " + "package from one of its components. The value will be " + "computed once the corresponding component is selected." + ), + ) + + @api.depends( + "move_raw_ids.propagate_package", + "move_raw_ids.move_line_ids.qty_done", + ) + def _compute_propagated_package_id(self): + for order in self: + order.propagated_package_id = False + move_with_package = order.move_raw_ids.filtered( + lambda o: o.propagate_package + ) + line_with_package = move_with_package.move_line_ids.filtered( + lambda l: l.package_id + ) + if len(line_with_package) == 1: + order.propagated_package_id = line_with_package.package_id + + @api.onchange("bom_id") + def _onchange_bom_id_package_propagation(self): + self.is_package_propagated = self.bom_id.package_propagation + + def action_confirm(self): + res = super().action_confirm() + self._check_package_propagation() + self._set_package_propagation_data_from_bom() + return res + + def _check_package_propagation(self): + """Ensure we can propagate the component package from the BoM.""" + for order in self: + bom = order.bom_id + if not bom.package_propagation: + continue + qty_ok = ( + tools.float_compare( + order.product_qty, + bom.product_qty, + precision_rounding=bom.product_uom_id.rounding, + ) + == 0 + ) + if not qty_ok or order.product_uom_id != bom.product_uom_id: + raise UserError( + _( + "The BoM is propagating a package from one component.\n" + "As such, the manufacturing order is forced to produce " + "the same quantity than the BoM: %s %s" + ) + % (bom.product_qty, bom.product_uom_id.display_name) + ) + + def _set_package_propagation_data_from_bom(self): + """Copy information from BoM to the manufacturing order.""" + for order in self: + order.is_package_propagated = order.bom_id.package_propagation + for move in order.move_raw_ids: + move.propagate_package = move.bom_line_id.propagate_package + + def _cal_price(self, consumed_moves): + # Overridden to propagate the package of the component + # to the finished product + # NOTE: this is the only method called in '_post_inventory' between + # the creation of the stock.move.line record on the finished move, + # and its validation. + self._create_and_assign_propagated_package() + return super()._cal_price(consumed_moves) + + def _create_and_assign_propagated_package(self): + for order in self: + if not order.is_package_propagated: + continue + finish_moves = order.move_finished_ids.filtered( + lambda m: m.product_id == order.product_id + and m.state not in ("done", "cancel") + ) + if finish_moves.move_line_ids: + finish_moves.move_line_ids.result_package_id = ( + order.propagated_package_id + ) diff --git a/mrp_package_propagation/models/stock_move.py b/mrp_package_propagation/models/stock_move.py new file mode 100644 index 000000000..456db3c75 --- /dev/null +++ b/mrp_package_propagation/models/stock_move.py @@ -0,0 +1,13 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + propagate_package = fields.Boolean( + default=False, + readonly=True, + ) diff --git a/mrp_package_propagation/readme/CONTRIBUTORS.rst b/mrp_package_propagation/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..c452804a9 --- /dev/null +++ b/mrp_package_propagation/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Sébastien Alix diff --git a/mrp_package_propagation/readme/DESCRIPTION.rst b/mrp_package_propagation/readme/DESCRIPTION.rst new file mode 100644 index 000000000..6ef8c2334 --- /dev/null +++ b/mrp_package_propagation/readme/DESCRIPTION.rst @@ -0,0 +1,11 @@ +Allow to propagate a package from a component to a finished product. + +This is useful for instance if you want to keep the box of one of the component +(which could have already a label stuck on it) for your finished product. + +Two constraints: + +* the component quantity has to be 1 unit +* the manufacturing order has to produce exactly the BoM quantity + +This is to ensure we get only one package reserved for the given component. diff --git a/mrp_package_propagation/readme/USAGE.rst b/mrp_package_propagation/readme/USAGE.rst new file mode 100644 index 000000000..ca5d4a5a6 --- /dev/null +++ b/mrp_package_propagation/readme/USAGE.rst @@ -0,0 +1,4 @@ +On the BoM: + +* enable the option "Package Propagation" +* flag one of the BoM line with "Propagate Package" diff --git a/mrp_package_propagation/static/description/index.html b/mrp_package_propagation/static/description/index.html new file mode 100644 index 000000000..e838ae1d0 --- /dev/null +++ b/mrp_package_propagation/static/description/index.html @@ -0,0 +1,436 @@ + + + + + + +MRP Package Propagation + + + +
+

MRP Package Propagation

+ + +

Alpha License: AGPL-3 OCA/manufacture Translate me on Weblate Try me on Runbot

+

Allow to propagate a package from a component to a finished product.

+
+

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

+

On the BoM:

+
    +
  • enable the option “Package Propagation”
  • +
  • flag one of the BoM line with “Propagate Package”
  • +
+
+
+

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.

+

Current maintainer:

+

sebalix

+

This module is part of the OCA/manufacture project on GitHub.

+

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

+
+
+
+ + diff --git a/mrp_package_propagation/tests/__init__.py b/mrp_package_propagation/tests/__init__.py new file mode 100644 index 000000000..6f95782dc --- /dev/null +++ b/mrp_package_propagation/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_mrp_bom +from . import test_mrp_production diff --git a/mrp_package_propagation/tests/common.py b/mrp_package_propagation/tests/common.py new file mode 100644 index 000000000..269183405 --- /dev/null +++ b/mrp_package_propagation/tests/common.py @@ -0,0 +1,69 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import random +import string + +from odoo.tests import common + + +class Common(common.SavepointCase): + + PACKAGE_NAME = "PROPAGATED-PKG" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.bom = cls.env.ref("mrp.mrp_bom_desk") + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None, in_date=None + ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) + cls.env["stock.quant"]._update_available_quantity( + product, + location, + quantity, + package_id=package, + lot_id=lot, + in_date=in_date, + ) + + @classmethod + def _update_stock_component_qty(cls, order=None, bom=None, location=None): + if not order and not bom: + return + if order: + bom = order.bom_id + if not location: + location = cls.env.ref("stock.stock_location_stock") + for line in bom.bom_line_ids: + if line.product_id.type != "product": + continue + lot = package = None + if line.product_id.tracking != "none": + lot_name = "".join( + random.choice(string.ascii_lowercase) for i in range(10) + ) + vals = { + "product_id": line.product_id.id, + "company_id": line.company_id.id, + "name": lot_name, + } + lot = cls.env["stock.production.lot"].create(vals) + if line.propagate_package: + vals = {"name": cls.PACKAGE_NAME} + package = cls.env["stock.quant.package"].create(vals) + cls._update_qty_in_location( + location, + line.product_id, + line.product_qty, + package=package, + lot=lot, + ) diff --git a/mrp_package_propagation/tests/test_mrp_bom.py b/mrp_package_propagation/tests/test_mrp_bom.py new file mode 100644 index 000000000..8f312b04a --- /dev/null +++ b/mrp_package_propagation/tests/test_mrp_bom.py @@ -0,0 +1,50 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.exceptions import ValidationError +from odoo.tests.common import Form + +from .common import Common + + +class TestMrpBom(Common): + def test_bom_display_package_propagation(self): + self.assertTrue(self.bom.display_package_propagation) + + def test_bom_line_check_propagate_package_multi(self): + form = Form(self.bom) + form.package_propagation = True + # Flag more than one line to propagate + for i in range(len(form.bom_line_ids)): + line_form = form.bom_line_ids.edit(i) + line_form.propagate_package = True + line_form.save() + with self.assertRaisesRegex(ValidationError, "Only one component"): + form.save() + + def test_bom_line_wrong_unit(self): + form = Form(self.bom) + form.package_propagation = True + # Set the wrong UoM on the line + line_form = form.bom_line_ids.edit(1) + line_form.propagate_package = True + line_form.product_uom_id = self.env.ref("uom.product_uom_dozen") + line_form.save() + with self.assertRaisesRegex(ValidationError, "The component propagating"): + form.save() + + def test_bom_line_wrong_qty(self): + form = Form(self.bom) + form.package_propagation = True + # Set the wrong qty on the line + line_form = form.bom_line_ids.edit(1) + line_form.propagate_package = True + line_form.product_qty = 2 + line_form.save() + with self.assertRaisesRegex(ValidationError, "The component propagating"): + form.save() + + def test_bom_check_propagate_package(self): + # Configure the BoM to propagate the package without enabling any line + with self.assertRaisesRegex(ValidationError, "a line has to be configured"): + self.bom.package_propagation = True diff --git a/mrp_package_propagation/tests/test_mrp_production.py b/mrp_package_propagation/tests/test_mrp_production.py new file mode 100644 index 000000000..55ae1a3dc --- /dev/null +++ b/mrp_package_propagation/tests/test_mrp_production.py @@ -0,0 +1,63 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.exceptions import UserError +from odoo.tests.common import Form + +from .common import Common + + +class TestMrpProduction(Common): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Configure the BoM to propagate package + with Form(cls.bom) as form: + form.package_propagation = True + line_form = form.bom_line_ids.edit(0) # Line tracked by SN + line_form.propagate_package = True + line_form.save() + form.save() + with Form(cls.env["mrp.production"]) as form: + form.bom_id = cls.bom + cls.order = form.save() + + def _set_qty_done(self, order): + for line in order.move_raw_ids.move_line_ids: + line.qty_done = line.product_uom_qty + order.qty_producing = order.product_qty + + def test_order_check_package_propagation(self): + self.assertTrue(self.order.is_package_propagated) + # Set a wrong quantity to produce + self.order.product_qty = 2 + with self.assertRaisesRegex(UserError, "The BoM is propagating a package"): + self.order.action_confirm() + self.order.product_qty = self.order.bom_id.product_qty + # Set a wrong UoM + self.order.product_uom_id = self.env.ref("uom.product_uom_dozen") + with self.assertRaisesRegex(UserError, "The BoM is propagating a package"): + self.order.action_confirm() + # Restore expected values to get the order validated + self.order.product_uom_id = self.order.bom_id.product_uom_id + self.order.product_qty = self.order.bom_id.product_qty + self.order.action_confirm() + + def test_order_propagated_package_id(self): + self.assertTrue(self.order.is_package_propagated) # set by onchange + self._update_stock_component_qty(self.order) + self.order.action_confirm() + self.order.action_assign() + self.assertTrue(self.order.is_package_propagated) # set by action_confirm + self.assertTrue(any(self.order.move_raw_ids.mapped("propagate_package"))) + self._set_qty_done(self.order) + self.assertEqual(self.order.propagated_package_id.name, self.PACKAGE_NAME) + + def test_order_post_inventory(self): + self._update_stock_component_qty(self.order) + self.order.action_confirm() + self.order.action_assign() + self._set_qty_done(self.order) + self.order.action_generate_serial() + self.order.button_mark_done() + self.assertEqual(self.order.propagated_package_id.name, self.PACKAGE_NAME) diff --git a/mrp_package_propagation/views/mrp_bom.xml b/mrp_package_propagation/views/mrp_bom.xml new file mode 100644 index 000000000..551fd0e4f --- /dev/null +++ b/mrp_package_propagation/views/mrp_bom.xml @@ -0,0 +1,28 @@ + + + + + + mrp.bom.form.inherit + mrp.bom + + + + + + + + + + + + + + diff --git a/mrp_package_propagation/views/mrp_production.xml b/mrp_package_propagation/views/mrp_production.xml new file mode 100644 index 000000000..4d33e3c7a --- /dev/null +++ b/mrp_package_propagation/views/mrp_production.xml @@ -0,0 +1,28 @@ + + + + + + mrp.production.form.inherit + mrp.production + + + + + + + + + + + diff --git a/setup/mrp_package_propagation/odoo/addons/mrp_package_propagation b/setup/mrp_package_propagation/odoo/addons/mrp_package_propagation new file mode 120000 index 000000000..03755858f --- /dev/null +++ b/setup/mrp_package_propagation/odoo/addons/mrp_package_propagation @@ -0,0 +1 @@ +../../../../mrp_package_propagation \ No newline at end of file diff --git a/setup/mrp_package_propagation/setup.py b/setup/mrp_package_propagation/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/mrp_package_propagation/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)