From 4671f29d2de6dd93a9b5f208193965adbda7e813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A0n=20Todorovich?= Date: Wed, 18 Jan 2023 14:57:04 -0300 Subject: [PATCH] [ADD] mrp_production_split --- mrp_production_split/README.rst | 1 + mrp_production_split/__init__.py | 2 + mrp_production_split/__manifest__.py | 21 +++ mrp_production_split/models/__init__.py | 1 + mrp_production_split/models/mrp_production.py | 34 ++++ mrp_production_split/readme/CONTRIBUTORS.rst | 3 + mrp_production_split/readme/DESCRIPTION.rst | 12 ++ mrp_production_split/readme/USAGE.rst | 5 + .../security/ir.model.access.csv | 2 + mrp_production_split/templates/messages.xml | 28 +++ mrp_production_split/tests/__init__.py | 1 + mrp_production_split/tests/common.py | 92 +++++++++ .../tests/test_mrp_production_split.py | 178 ++++++++++++++++++ mrp_production_split/views/mrp_production.xml | 30 +++ mrp_production_split/wizards/__init__.py | 1 + .../wizards/mrp_production_split_wizard.py | 167 ++++++++++++++++ .../wizards/mrp_production_split_wizard.xml | 57 ++++++ .../odoo/addons/mrp_production_split | 1 + setup/mrp_production_split/setup.py | 6 + 19 files changed, 642 insertions(+) create mode 100644 mrp_production_split/README.rst create mode 100644 mrp_production_split/__init__.py create mode 100644 mrp_production_split/__manifest__.py create mode 100644 mrp_production_split/models/__init__.py create mode 100644 mrp_production_split/models/mrp_production.py create mode 100644 mrp_production_split/readme/CONTRIBUTORS.rst create mode 100644 mrp_production_split/readme/DESCRIPTION.rst create mode 100644 mrp_production_split/readme/USAGE.rst create mode 100644 mrp_production_split/security/ir.model.access.csv create mode 100644 mrp_production_split/templates/messages.xml create mode 100644 mrp_production_split/tests/__init__.py create mode 100644 mrp_production_split/tests/common.py create mode 100644 mrp_production_split/tests/test_mrp_production_split.py create mode 100644 mrp_production_split/views/mrp_production.xml create mode 100644 mrp_production_split/wizards/__init__.py create mode 100644 mrp_production_split/wizards/mrp_production_split_wizard.py create mode 100644 mrp_production_split/wizards/mrp_production_split_wizard.xml create mode 120000 setup/mrp_production_split/odoo/addons/mrp_production_split create mode 100644 setup/mrp_production_split/setup.py diff --git a/mrp_production_split/README.rst b/mrp_production_split/README.rst new file mode 100644 index 000000000..b2c0bd68a --- /dev/null +++ b/mrp_production_split/README.rst @@ -0,0 +1 @@ +# TO BE GENERATED diff --git a/mrp_production_split/__init__.py b/mrp_production_split/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/mrp_production_split/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/mrp_production_split/__manifest__.py b/mrp_production_split/__manifest__.py new file mode 100644 index 000000000..2dd518a41 --- /dev/null +++ b/mrp_production_split/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "MRP Production Split", + "summary": "Split Manufacturing Orders into smaller ones", + "version": "15.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["ivantodorovich"], + "website": "https://github.com/OCA/manufacture", + "license": "AGPL-3", + "category": "Manufacturing", + "depends": ["mrp"], + "data": [ + "security/ir.model.access.csv", + "templates/messages.xml", + "views/mrp_production.xml", + "wizards/mrp_production_split_wizard.xml", + ], +} diff --git a/mrp_production_split/models/__init__.py b/mrp_production_split/models/__init__.py new file mode 100644 index 000000000..a9e5f13e4 --- /dev/null +++ b/mrp_production_split/models/__init__.py @@ -0,0 +1 @@ +from . import mrp_production diff --git a/mrp_production_split/models/mrp_production.py b/mrp_production_split/models/mrp_production.py new file mode 100644 index 000000000..d80edc4a1 --- /dev/null +++ b/mrp_production_split/models/mrp_production.py @@ -0,0 +1,34 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models +from odoo.exceptions import UserError + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + def copy_data(self, default=None): + # OVERRIDE copy the date_planned_start and date_planned_end when splitting + # productions, as they are not copied by default (copy=False). + [data] = super().copy_data(default=default) + data.setdefault("date_planned_start", self.date_planned_start) + data.setdefault("date_planned_finished", self.date_planned_finished) + return [data] + + def action_split(self): + self.ensure_one() + self._check_company() + if self.state in ("draft", "done", "to_close", "cancel"): + raise UserError( + _( + "Cannot split a manufacturing order that is in '%s' state.", + self._fields["state"].convert_to_export(self.state, self), + ) + ) + action = self.env["ir.actions.actions"]._for_xml_id( + "mrp_production_split.action_mrp_production_split_wizard" + ) + action["context"] = {"default_production_id": self.id} + return action diff --git a/mrp_production_split/readme/CONTRIBUTORS.rst b/mrp_production_split/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..a1e0a8395 --- /dev/null +++ b/mrp_production_split/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_ + + * Iván Todorovich diff --git a/mrp_production_split/readme/DESCRIPTION.rst b/mrp_production_split/readme/DESCRIPTION.rst new file mode 100644 index 000000000..26ff02135 --- /dev/null +++ b/mrp_production_split/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +This module adds a "Split" button to Manufacturing Orders. + +Manufacturing Orders can be split as long as they haven't been completed yet. + +For products tracked by "Serial Number", it allows to choose the Quantity to extract +from the original MO, and it'll create one MO per single unit. + +For other products, more options are available, that will let you do things like: + +* Extract 10 units from a MO into 5 MOs of 2 units each. +* Extract 10 units from a MO into a single new MOs. +* Extract 10 units from a MO into multiple MOs of different quantities. diff --git a/mrp_production_split/readme/USAGE.rst b/mrp_production_split/readme/USAGE.rst new file mode 100644 index 000000000..84ac9e3c6 --- /dev/null +++ b/mrp_production_split/readme/USAGE.rst @@ -0,0 +1,5 @@ +#. Create a Manufacturing Order. +#. Confirm it. +#. Click on the "Split" button. +#. Choose the desired split options. +#. Confirm. diff --git a/mrp_production_split/security/ir.model.access.csv b/mrp_production_split/security/ir.model.access.csv new file mode 100644 index 000000000..21624df93 --- /dev/null +++ b/mrp_production_split/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mrp_production_split_wizard,access.mrp.production.split.wizard,model_mrp_production_split_wizard,mrp.group_mrp_user,1,1,1,1 diff --git a/mrp_production_split/templates/messages.xml b/mrp_production_split/templates/messages.xml new file mode 100644 index 000000000..5e67a8b7d --- /dev/null +++ b/mrp_production_split/templates/messages.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/mrp_production_split/tests/__init__.py b/mrp_production_split/tests/__init__.py new file mode 100644 index 000000000..87705603d --- /dev/null +++ b/mrp_production_split/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mrp_production_split diff --git a/mrp_production_split/tests/common.py b/mrp_production_split/tests/common.py new file mode 100644 index 000000000..48fe22bac --- /dev/null +++ b/mrp_production_split/tests/common.py @@ -0,0 +1,92 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests import Form, TransactionCase + + +class CommonCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + # Create bom, product and components + cls.component = cls.env["product.product"].create( + { + "name": "Component", + "detailed_type": "product", + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Product", + "detailed_type": "product", + "tracking": "lot", + } + ) + cls.product_bom = cls.env["mrp.bom"].create( + { + "product_tmpl_id": cls.product.product_tmpl_id.id, + "product_qty": 1.0, + "product_uom_id": cls.product.uom_id.id, + "bom_line_ids": [ + Command.create( + { + "product_id": cls.component.id, + "product_qty": 1.0, + "product_uom_id": cls.component.uom_id.id, + } + ) + ], + } + ) + # Create some initial stocks + cls.location_stock = cls.env.ref("stock.stock_location_stock") + cls.env["stock.quant"].create( + { + "product_id": cls.component.id, + "product_uom_id": cls.component.uom_id.id, + "location_id": cls.location_stock.id, + "quantity": 10.00, + } + ) + # Create the MO + cls.production = cls._create_mrp_production( + product=cls.product, + bom=cls.product_bom, + ) + + @classmethod + def _create_mrp_production( + cls, product=None, bom=None, quantity=5.0, confirm=False + ): + if product is None: # pragma: no cover + product = cls.product + if bom is None: # pragma: no cover + bom = cls.product_bom + mo_form = Form(cls.env["mrp.production"]) + mo_form.product_id = product + mo_form.bom_id = bom + mo_form.product_qty = quantity + mo_form.product_uom_id = product.uom_id + mo = mo_form.save() + if confirm: # pragma: no cover + mo.action_confirm() + return mo + + def _mrp_production_set_quantity_done(self, order): + for line in order.move_raw_ids.move_line_ids: + line.qty_done = line.product_uom_qty + order.move_raw_ids._recompute_state() + order.qty_producing = order.product_qty + + def _mrp_production_split(self, order, **vals): + action = order.action_split() + Wizard = self.env[action["res_model"]] + Wizard = Wizard.with_context(active_model=order._name, active_id=order.id) + Wizard = Wizard.with_context(**action["context"]) + wizard = Wizard.create(vals) + res = wizard.apply() + records = self.env[res["res_model"]].search(res["domain"]) + return records diff --git a/mrp_production_split/tests/test_mrp_production_split.py b/mrp_production_split/tests/test_mrp_production_split.py new file mode 100644 index 000000000..ccb6f4b70 --- /dev/null +++ b/mrp_production_split/tests/test_mrp_production_split.py @@ -0,0 +1,178 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo.exceptions import UserError + +from .common import CommonCase + + +class TestMrpProductionSplit(CommonCase): + def test_mrp_production_split_draft(self): + with self.assertRaisesRegex(UserError, r"Cannot split.*"): + self._mrp_production_split(self.production) + + def test_mrp_production_split_done(self): + self.production.action_confirm() + self.production.action_generate_serial() + self._mrp_production_set_quantity_done(self.production) + self.production.button_mark_done() + with self.assertRaisesRegex(UserError, r"Cannot split.*"): + self._mrp_production_split(self.production) + + def test_mrp_production_split_cancel(self): + self.production.action_cancel() + with self.assertRaisesRegex(UserError, r"Cannot split.*"): + self._mrp_production_split(self.production) + + def test_mrp_production_split_lot_simple(self): + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split(self.production, split_qty=2.0) + self.assertRecordValues(mos, [dict(product_qty=3.0), dict(product_qty=2.0)]) + + def test_mrp_production_split_lot_simple_copy_date_planned(self): + dt_start = datetime.now() + timedelta(days=5) + dt_finished = dt_start + timedelta(hours=1) + self.production.date_planned_start = dt_start + self.production.date_planned_finished = dt_finished + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split(self.production, split_qty=2.0) + self.assertRecordValues( + mos, + [ + dict( + product_qty=3.0, + date_planned_start=dt_start, + date_planned_finished=dt_finished, + ), + dict( + product_qty=2.0, + date_planned_start=dt_start, + date_planned_finished=dt_finished, + ), + ], + ) + + def test_mrp_production_split_lot_simple_zero_qty(self): + self.production.action_confirm() + self.production.action_generate_serial() + with self.assertRaisesRegex(UserError, r"Nothing to split.*"): + self._mrp_production_split(self.production, split_qty=0.0) + + def test_mrp_production_split_lot_simple_with_qty_producing_exceeded(self): + self.production.action_confirm() + self.production.action_generate_serial() + self.production.qty_producing = 3.0 + with self.assertRaisesRegex(UserError, r"You can't split.*"): + self._mrp_production_split(self.production, split_qty=4.0) + + def test_mrp_production_split_lot_equal(self): + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split( + self.production, + split_mode="equal", + split_qty=4.0, + split_equal_qty=2.0, + ) + self.assertRecordValues( + mos, + [ + dict(product_qty=1.0), + dict(product_qty=2.0), + dict(product_qty=2.0), + ], + ) + + def test_mrp_production_split_lot_custom(self): + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split( + self.production, + split_mode="custom", + custom_quantities="1 2 1 1", + ) + self.assertRecordValues( + mos, + [ + dict(product_qty=1.0), + dict(product_qty=2.0), + dict(product_qty=1.0), + dict(product_qty=1.0), + ], + ) + + def test_mrp_production_split_lot_custom_incomplete(self): + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split( + self.production, + split_mode="custom", + custom_quantities="1 2", + ) + self.assertRecordValues( + mos, + [ + dict(product_qty=2.0), + dict(product_qty=1.0), + dict(product_qty=2.0), + ], + ) + + def test_mrp_production_split_lot_custom_float(self): + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split( + self.production, + split_mode="custom", + custom_quantities="1.0 2.0 1.0 1.0", + ) + self.assertRecordValues( + mos, + [ + dict(product_qty=1.0), + dict(product_qty=2.0), + dict(product_qty=1.0), + dict(product_qty=1.0), + ], + ) + + def test_mrp_production_split_lot_custom_float_locale(self): + lang = self.env["res.lang"]._lang_get(self.env.user.lang) + lang.decimal_point = "," + lang.thousands_sep = "" + self.production.action_confirm() + self.production.action_generate_serial() + mos = self._mrp_production_split( + self.production, + split_mode="custom", + custom_quantities="1,0 2,0 1,0 1,0", + ) + self.assertRecordValues( + mos, + [ + dict(product_qty=1.0), + dict(product_qty=2.0), + dict(product_qty=1.0), + dict(product_qty=1.0), + ], + ) + + def test_mrp_production_split_serial(self): + self.product.tracking = "serial" + self.production.action_confirm() + mos = self._mrp_production_split(self.production) + self.assertRecordValues( + mos, + [ + dict(product_qty=1.0), + dict(product_qty=1.0), + dict(product_qty=1.0), + dict(product_qty=1.0), + dict(product_qty=1.0), + ], + ) diff --git a/mrp_production_split/views/mrp_production.xml b/mrp_production_split/views/mrp_production.xml new file mode 100644 index 000000000..82e61f7cf --- /dev/null +++ b/mrp_production_split/views/mrp_production.xml @@ -0,0 +1,30 @@ + + + + + + mrp.production + + + + + + + diff --git a/mrp_production_split/wizards/__init__.py b/mrp_production_split/wizards/__init__.py new file mode 100644 index 000000000..c27c5fddf --- /dev/null +++ b/mrp_production_split/wizards/__init__.py @@ -0,0 +1 @@ +from . import mrp_production_split_wizard diff --git a/mrp_production_split/wizards/mrp_production_split_wizard.py b/mrp_production_split/wizards/mrp_production_split_wizard.py new file mode 100644 index 000000000..bc9bef63a --- /dev/null +++ b/mrp_production_split/wizards/mrp_production_split_wizard.py @@ -0,0 +1,167 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from typing import List, Union + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class MrpProductionSplitWizard(models.TransientModel): + _name = "mrp.production.split.wizard" + + production_id = fields.Many2one( + "mrp.production", + "Production", + required=True, + ondelete="cascade", + ) + split_mode = fields.Selection( + [ + ("simple", "Extract a quantity from the original MO"), + ("equal", "Extract a quantity into several MOs with equal quantities"), + ("custom", "Custom"), + ], + required=True, + default="simple", + ) + split_qty = fields.Float( + string="Quantity", + digits="Product Unit of Measure", + help="Total quantity to extract from the original MO.", + ) + split_equal_qty = fields.Float( + string="Equal Quantity", + digits="Product Unit of Measure", + help="Used to split the MO into several MOs with equal quantities.", + default=1, + ) + custom_quantities = fields.Char( + string="Split Quantities", + help="Space separated list of quantities to split:\n" + "e.g. '3 2 5' will result in 3 MOs with 3, 2 and 5 units respectively.\n" + "If the sum of the quantities is less than the original MO's quantity, the " + "remaining quantity will remain in the original MO.", + ) + product_tracking = fields.Selection(related="production_id.product_id.tracking") + product_uom_id = fields.Many2one(related="production_id.product_uom_id") + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_model = self.env.context.get("active_model") + active_id = self.env.context.get("active_id") + # Auto-complete production_id from context + if "production_id" in fields_list and active_model == "mrp.production": + res["production_id"] = active_id + # Auto-complete split_mode from production_id + if "split_mode" in fields_list and res.get("production_id"): + production = self.env["mrp.production"].browse(res["production_id"]) + if production.product_tracking == "serial": + res["split_mode"] = "equal" + # Auto-complete split_qty from production_id + if "split_qty" in fields_list and res.get("production_id"): + production = self.env["mrp.production"].browse(res["production_id"]) + res["split_qty"] = production._get_quantity_to_backorder() + return res + + @api.model + def _parse_float(self, value: Union[float, int, str]) -> float: + """Parse a float number from a string, with the user's language settings.""" + if isinstance(value, (float, int)): # pragma: no cover + return float(value) + lang = self.env["res.lang"]._lang_get(self.env.user.lang) + try: + return float( + value.replace(lang.thousands_sep, "") + .replace(lang.decimal_point, ".") + .strip() + ) + except ValueError as e: # pragma: no cover + raise UserError(_("%s is not a number.", value)) from e + + @api.model + def _parse_float_list(self, value: str) -> List[float]: + """Parse a list of float numbers from a string.""" + return [self._parse_float(v) for v in value.split()] + + @api.onchange("custom_quantities") + def _onchange_custom_quantities_check(self): + """Check that the custom quantities are valid.""" + if self.custom_quantities: # pragma: no cover + try: + self._parse_float_list(self.custom_quantities) + except UserError: + return { + "warning": { + "title": _("Invalid quantities"), + "message": _("Please enter a space separated list of numbers."), + } + } + + def _get_split_quantities(self) -> List[float]: + """Return the quantities to split, according to the settings.""" + production = self.production_id + rounding = production.product_uom_id.rounding + if self.split_mode == "simple": + if ( + fields.Float.compare( + self.split_qty, production._get_quantity_to_backorder(), rounding + ) + > 0 + ): + raise UserError(_("You can't split quantities already in production.")) + if fields.Float.is_zero(self.split_qty, precision_rounding=rounding): + raise UserError(_("Nothing to split.")) + return [production.product_qty - self.split_qty, self.split_qty] + elif self.split_mode == "equal": + split_total = min(production._get_quantity_to_backorder(), self.split_qty) + split_count = int(split_total // self.split_equal_qty) + split_rest = production.product_qty - split_total + split_rest += split_total % self.split_equal_qty + quantities = [self.split_equal_qty] * split_count + if not fields.Float.is_zero(split_rest, precision_rounding=rounding): + quantities = [split_rest] + quantities + return quantities + elif self.split_mode == "custom": + quantities = self._parse_float_list(self.custom_quantities) + split_total = sum(quantities) + split_rest = production.product_qty - split_total + if not fields.Float.is_zero(split_rest, precision_rounding=rounding): + quantities = [split_rest] + quantities + return quantities + else: # pragma: no cover + raise UserError(_("Invalid Split Mode: '%s'", self.split_mode)) + + def _apply(self): + self.ensure_one() + records = self.production_id.with_context( + copy_date_planned=True + )._split_productions( + amounts={self.production_id: self._get_split_quantities()}, + cancel_remaning_qty=False, + set_consumed_qty=False, + ) + new_records = records - self.production_id + for record in new_records: + record.message_post_with_view( + "mail.message_origin_link", + values=dict(self=record, origin=self.production_id), + message_log=True, + ) + if new_records: + self.production_id.message_post_with_view( + "mrp_production_split.message_order_split", + values=dict(self=self.production_id, records=new_records), + message_log=True, + ) + return records + + def apply(self): + records = self._apply() + action = self.env["ir.actions.act_window"]._for_xml_id( + "mrp.mrp_production_action" + ) + action["domain"] = [("id", "in", records.ids)] + return action diff --git a/mrp_production_split/wizards/mrp_production_split_wizard.xml b/mrp_production_split/wizards/mrp_production_split_wizard.xml new file mode 100644 index 000000000..d8a3ff9f0 --- /dev/null +++ b/mrp_production_split/wizards/mrp_production_split_wizard.xml @@ -0,0 +1,57 @@ + + + + + + mrp.production.split.wizard + +
+ + + + + + + + +
+
+
+
+
+ + + Split Manufacturing Order + ir.actions.act_window + mrp.production.split.wizard + form + new + + +
diff --git a/setup/mrp_production_split/odoo/addons/mrp_production_split b/setup/mrp_production_split/odoo/addons/mrp_production_split new file mode 120000 index 000000000..47e3373f3 --- /dev/null +++ b/setup/mrp_production_split/odoo/addons/mrp_production_split @@ -0,0 +1 @@ +../../../../mrp_production_split \ No newline at end of file diff --git a/setup/mrp_production_split/setup.py b/setup/mrp_production_split/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/mrp_production_split/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)