From f1e4bfe52c38eed5bd354725d8f51a53eaa45d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 7 Sep 2022 10:43:54 +0200 Subject: [PATCH] [ADD] mrp_lot_number_propagation --- mrp_lot_number_propagation/README.rst | 1 + mrp_lot_number_propagation/__init__.py | 1 + mrp_lot_number_propagation/__manifest__.py | 20 +++ mrp_lot_number_propagation/models/__init__.py | 4 + mrp_lot_number_propagation/models/mrp_bom.py | 74 +++++++++ .../models/mrp_bom_line.py | 62 +++++++ .../models/mrp_production.py | 152 ++++++++++++++++++ .../models/stock_move.py | 13 ++ .../readme/CONTRIBUTORS.rst | 2 + .../readme/DESCRIPTION.rst | 1 + mrp_lot_number_propagation/readme/ROADMAP.rst | 1 + mrp_lot_number_propagation/tests/__init__.py | 1 + mrp_lot_number_propagation/tests/common.py | 92 +++++++++++ .../tests/test_mrp_bom.py | 36 +++++ .../tests/test_mrp_production.py | 44 +++++ mrp_lot_number_propagation/views/mrp_bom.xml | 28 ++++ .../views/mrp_production.xml | 25 +++ 17 files changed, 557 insertions(+) create mode 100644 mrp_lot_number_propagation/README.rst create mode 100644 mrp_lot_number_propagation/__init__.py create mode 100644 mrp_lot_number_propagation/__manifest__.py create mode 100644 mrp_lot_number_propagation/models/__init__.py create mode 100644 mrp_lot_number_propagation/models/mrp_bom.py create mode 100644 mrp_lot_number_propagation/models/mrp_bom_line.py create mode 100644 mrp_lot_number_propagation/models/mrp_production.py create mode 100644 mrp_lot_number_propagation/models/stock_move.py create mode 100644 mrp_lot_number_propagation/readme/CONTRIBUTORS.rst create mode 100644 mrp_lot_number_propagation/readme/DESCRIPTION.rst create mode 100644 mrp_lot_number_propagation/readme/ROADMAP.rst create mode 100644 mrp_lot_number_propagation/tests/__init__.py create mode 100644 mrp_lot_number_propagation/tests/common.py create mode 100644 mrp_lot_number_propagation/tests/test_mrp_bom.py create mode 100644 mrp_lot_number_propagation/tests/test_mrp_production.py create mode 100644 mrp_lot_number_propagation/views/mrp_bom.xml create mode 100644 mrp_lot_number_propagation/views/mrp_production.xml diff --git a/mrp_lot_number_propagation/README.rst b/mrp_lot_number_propagation/README.rst new file mode 100644 index 000000000..9c558e357 --- /dev/null +++ b/mrp_lot_number_propagation/README.rst @@ -0,0 +1 @@ +. diff --git a/mrp_lot_number_propagation/__init__.py b/mrp_lot_number_propagation/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/mrp_lot_number_propagation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mrp_lot_number_propagation/__manifest__.py b/mrp_lot_number_propagation/__manifest__.py new file mode 100644 index 000000000..8e15c07e4 --- /dev/null +++ b/mrp_lot_number_propagation/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "MRP Serial Number Propagation", + "version": "15.0.0.1.0", + "development_status": "Alpha", + "license": "AGPL-3", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["sebalix"], + "summary": "Propagate a serial number 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_lot_number_propagation/models/__init__.py b/mrp_lot_number_propagation/models/__init__.py new file mode 100644 index 000000000..6028eeffd --- /dev/null +++ b/mrp_lot_number_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_lot_number_propagation/models/mrp_bom.py b/mrp_lot_number_propagation/models/mrp_bom.py new file mode 100644 index 000000000..e9e574891 --- /dev/null +++ b/mrp_lot_number_propagation/models/mrp_bom.py @@ -0,0 +1,74 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models, tools + + +class MrpBom(models.Model): + _inherit = "mrp.bom" + + lot_number_propagation = fields.Boolean( + default=False, + help=( + "Allow to propagate the lot/serial number " + "from a component to the finished product." + ), + ) + display_lot_number_propagation = fields.Boolean( + compute="_compute_display_lot_number_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_lot_number_propagation(self): + """Check if a lot number can be propagated. + + A lot number can be propagated from a component to the finished product if: + - the type of the BoM is normal (Manufacture this product) + - the finished product is tracked by serial number + - the quantity of the finished product is 1 and its UoM is unit + - there is at least one bom line, with a component tracked by serial, + having a quantity of 1 and its UoM is unit + """ + uom_unit = self.env.ref("uom.product_uom_unit") + for bom in self: + bom.display_lot_number_propagation = ( + bom.type in self._get_lot_number_propagation_bom_types() + and bom.product_tmpl_id.tracking == "serial" + and tools.float_compare( + bom.product_qty, 1, precision_rounding=bom.product_uom_id.rounding + ) + == 0 + and bom.product_uom_id == uom_unit + and bom._has_tracked_product_to_propagate() + ) + + def _get_lot_number_propagation_bom_types(self): + return ["normal"] + + def _has_tracked_product_to_propagate(self): + self.ensure_one() + uom_unit = self.env.ref("uom.product_uom_unit") + for line in self.bom_line_ids: + if ( + line.product_id.tracking == "serial" + and tools.float_compare( + line.product_qty, 1, precision_rounding=line.product_uom_id.rounding + ) + == 0 + and line.product_uom_id == uom_unit + ): + return True + return False + + @api.onchange("display_lot_number_propagation") + def onchange_display_lot_number_propagation(self): + if not self.display_lot_number_propagation: + self.lot_number_propagation = False diff --git a/mrp_lot_number_propagation/models/mrp_bom_line.py b/mrp_lot_number_propagation/models/mrp_bom_line.py new file mode 100644 index 000000000..093cf0979 --- /dev/null +++ b/mrp_lot_number_propagation/models/mrp_bom_line.py @@ -0,0 +1,62 @@ +# 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 MrpBomLine(models.Model): + _inherit = "mrp.bom.line" + + propagate_lot_number = fields.Boolean( + default=False, + ) + display_propagate_lot_number = fields.Boolean( + compute="_compute_display_propagate_lot_number" + ) + + @api.depends( + "bom_id.display_lot_number_propagation", + "bom_id.lot_number_propagation", + ) + def _compute_display_propagate_lot_number(self): + for line in self: + line.display_propagate_lot_number = ( + line.bom_id.display_lot_number_propagation + and line.bom_id.lot_number_propagation + ) + + @api.constrains( + "propagate_lot_number", + "bom_id.lot_number_propagation", + "product_id.tracking", + "bom_id.product_tmpl_id.tracking", + ) + def _check_propagate_lot_number(self): + """ + This function should check: + + - if the bom has lot_number_propagation marked, there is one and + only one line of this bom with propagate_lot_number marked. + - the bom line being marked with lot_number_propagation is of the same + tracking type as the finished product + """ + for line in self: + lines_to_propagate = line.bom_id.bom_line_ids.filtered( + lambda o: o.propagate_lot_number + ) + if line.bom_id.lot_number_propagation: + if len(lines_to_propagate) > 1: + raise ValidationError( + _( + "Only one BoM line can propagate its lot/serial number " + "to the finished product." + ) + ) + if line.propagate_lot_number and line.product_id.tracking != "serial": + raise ValidationError( + _( + "Only components tracked by serial number can propagate " + "its lot/serial number to the finished product." + ) + ) diff --git a/mrp_lot_number_propagation/models/mrp_production.py b/mrp_lot_number_propagation/models/mrp_production.py new file mode 100644 index 000000000..3bc066377 --- /dev/null +++ b/mrp_lot_number_propagation/models/mrp_production.py @@ -0,0 +1,152 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from lxml import etree + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.base.models.ir_ui_view import ( + transfer_modifiers_to_node, + transfer_node_to_modifiers, +) + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + is_lot_number_propagated = fields.Boolean( + default=False, + readonly=True, + help=( + "Lot/serial number is propagated " + "from a component to the finished product." + ), + ) + propagated_lot_producing = fields.Char( + compute="_compute_propagated_lot_producing", + help=( + "The BoM used on this manufacturing order is set to propagate " + "lot number from one of its components. The value will be " + "computed once the corresponding component is selected." + ), + ) + + @api.depends( + "move_raw_ids.propagate_lot_number", + "move_raw_ids.move_line_ids.qty_done", + "move_raw_ids.move_line_ids.lot_id", + ) + def _compute_propagated_lot_producing(self): + for order in self: + order.propagated_lot_producing = False + move_with_lot = order.move_raw_ids.filtered( + lambda o: o.propagate_lot_number + ) + line_with_sn = move_with_lot.move_line_ids.filtered( + lambda l: ( + l.lot_id + and l.product_id.tracking == "serial" + and tools.float_compare( + l.qty_done, 1, precision_rounding=l.product_uom_id.rounding + ) + == 0 + ) + ) + if len(line_with_sn) == 1: + order.propagated_lot_producing = line_with_sn.lot_id.name + + @api.onchange("bom_id") + def _onchange_bom_id_lot_number_propagation(self): + self.is_lot_number_propagated = self.bom_id.lot_number_propagation + + def action_confirm(self): + res = super().action_confirm() + self._set_lot_number_propagation_data_from_bom() + return res + + def _set_lot_number_propagation_data_from_bom(self): + """Copy information from BoM to the manufacturing order.""" + for order in self: + order.is_lot_number_propagated = order.bom_id.lot_number_propagation + for move in order.move_raw_ids: + move.propagate_lot_number = move.bom_line_id.propagate_lot_number + + def _post_inventory(self, cancel_backorder=False): + self._create_and_assign_propagated_lot_number() + return super()._post_inventory(cancel_backorder=cancel_backorder) + + def _create_and_assign_propagated_lot_number(self): + for order in self: + if not order.is_lot_number_propagated or order.lot_producing_id: + 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 and not finish_moves.quantity_done: + lot = self.env["stock.production.lot"].create( + { + "product_id": order.product_id.id, + "company_id": order.company_id.id, + "name": order.propagated_lot_producing, + } + ) + order.with_context(lot_propagation=True).lot_producing_id = lot + + def write(self, vals): + for order in self: + if ( + order.is_lot_number_propagated + and "lot_producing_id" in vals + and not self.env.context.get("lot_propagation") + ): + raise UserError( + _( + "Lot/Serial number is propagated from a component, " + "you are not allowed to change it." + ) + ) + return super().write(vals) + + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + # Override to hide the "lot_producing_id" field + "action_generate_serial" + # button if the MO is configured to propagate a serial number + result = super().fields_view_get( + view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu + ) + if result.get("name") in self._views_to_adapt(): + result["arch"] = self._fields_view_get_adapt_lot_tags_attrs(result) + return result + + def _views_to_adapt(self): + """Return the form view names bound to 'mrp.production' to adapt.""" + return ["mrp.production.form"] + + def _fields_view_get_adapt_lot_tags_attrs(self, view): + """Hide elements related to lot if it is automatically propagated.""" + doc = etree.XML(view["arch"]) + tags = ( + "//label[@for='lot_producing_id']", + "//field[@name='lot_producing_id']/..", # parent
+ ) + for xpath_expr in tags: + attrs_key = "invisible" + nodes = doc.xpath(xpath_expr) + for field in nodes: + attrs = safe_eval(field.attrib.get("attrs", "{}")) + if not attrs[attrs_key]: + continue + invisible_domain = expression.OR( + [attrs[attrs_key], [("is_lot_number_propagated", "=", True)]] + ) + attrs[attrs_key] = invisible_domain + field.set("attrs", str(attrs)) + modifiers = {} + transfer_node_to_modifiers(field, modifiers, self.env.context) + transfer_modifiers_to_node(modifiers, field) + return etree.tostring(doc, encoding="unicode") diff --git a/mrp_lot_number_propagation/models/stock_move.py b/mrp_lot_number_propagation/models/stock_move.py new file mode 100644 index 000000000..cda1fba97 --- /dev/null +++ b/mrp_lot_number_propagation/models/stock_move.py @@ -0,0 +1,13 @@ +# Copyright 2022 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_lot_number = fields.Boolean( + default=False, + readonly=True, + ) diff --git a/mrp_lot_number_propagation/readme/CONTRIBUTORS.rst b/mrp_lot_number_propagation/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..313df49ab --- /dev/null +++ b/mrp_lot_number_propagation/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Akim Juillerat +* Sébastien Alix diff --git a/mrp_lot_number_propagation/readme/DESCRIPTION.rst b/mrp_lot_number_propagation/readme/DESCRIPTION.rst new file mode 100644 index 000000000..d2563bc4a --- /dev/null +++ b/mrp_lot_number_propagation/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Allow to propagate a lot number from a component to a finished product. diff --git a/mrp_lot_number_propagation/readme/ROADMAP.rst b/mrp_lot_number_propagation/readme/ROADMAP.rst new file mode 100644 index 000000000..38015d2db --- /dev/null +++ b/mrp_lot_number_propagation/readme/ROADMAP.rst @@ -0,0 +1 @@ +* Add compatibility with lot number (in addition to serial number) diff --git a/mrp_lot_number_propagation/tests/__init__.py b/mrp_lot_number_propagation/tests/__init__.py new file mode 100644 index 000000000..a16beef0b --- /dev/null +++ b/mrp_lot_number_propagation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mrp_bom diff --git a/mrp_lot_number_propagation/tests/common.py b/mrp_lot_number_propagation/tests/common.py new file mode 100644 index 000000000..9a81d7d85 --- /dev/null +++ b/mrp_lot_number_propagation/tests/common.py @@ -0,0 +1,92 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import random +import string + +from odoo import fields +from odoo.tests import common + + +class Common(common.TransactionCase): + + LOT_NAME = "PROPAGATED-LOT" + + @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") + cls.product_tracked_by_lot = cls.env.ref( + "mrp.product_product_computer_desk_leg" + ) + cls.product_tracked_by_sn = cls.env.ref( + "mrp.product_product_computer_desk_head" + ) + cls.line_tracked_by_lot = cls.bom.bom_line_ids.filtered( + lambda o: o.product_id == cls.product_tracked_by_lot + ) + cls.line_tracked_by_sn = cls.bom.bom_line_ids.filtered( + lambda o: o.product_id == cls.product_tracked_by_sn + ) + cls.line_no_tracking = fields.first( + cls.bom.bom_line_ids.filtered(lambda o: o.product_id.tracking == "none") + ) + + @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 = None + if line.product_id.tracking != "none": + lot_name = "".join( + random.choice(string.ascii_lowercase) for i in range(10) + ) + if line.propagate_lot_number: + lot_name = cls.LOT_NAME + vals = { + "product_id": line.product_id.id, + "company_id": line.company_id.id, + "name": lot_name, + } + lot = cls.env["stock.production.lot"].create(vals) + cls._update_qty_in_location( + location, + line.product_id, + line.product_qty, + lot=lot, + ) + + @classmethod + def _get_lot_quants(cls, lot, location=None): + quants = lot.quant_ids.filtered(lambda q: q.quantity > 0) + if location: + quants = quants.filtered( + lambda q: q.location_id.parent_path in location.parent_path + ) + return quants diff --git a/mrp_lot_number_propagation/tests/test_mrp_bom.py b/mrp_lot_number_propagation/tests/test_mrp_bom.py new file mode 100644 index 000000000..49f644aed --- /dev/null +++ b/mrp_lot_number_propagation/tests/test_mrp_bom.py @@ -0,0 +1,36 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.exceptions import ValidationError + +from .common import Common + + +class TestMrpBom(Common): + def test_bom_display_lot_number_propagation(self): + self.assertTrue(self.bom.display_lot_number_propagation) + self.bom.product_tmpl_id.tracking = "none" + self.assertFalse(self.bom.display_lot_number_propagation) + + def test_bom_line_check_propagate_lot_number_multi(self): + self.bom.lot_number_propagation = True + # Flag more than one line to propagate + with self.assertRaisesRegex(ValidationError, "Only one BoM"): + self.bom.bom_line_ids.write({"propagate_lot_number": True}) + + def test_bom_line_check_propagate_lot_number_not_tracked(self): + self.bom.lot_number_propagation = True + # Flag a line that can't be propagated + with self.assertRaisesRegex(ValidationError, "Only components tracked"): + self.line_no_tracking.propagate_lot_number = True + + def test_bom_line_check_propagate_lot_number_tracked_by_lot(self): + self.bom.lot_number_propagation = True + # Flag a line tracked by lot (not SN) which is not supported + with self.assertRaisesRegex(ValidationError, "Only components tracked"): + self.line_tracked_by_lot.propagate_lot_number = True + + def test_bom_line_check_propagate_lot_number_same_tracking(self): + self.bom.lot_number_propagation = True + # Flag a line whose tracking type is the same than the finished product + self.line_tracked_by_sn.propagate_lot_number = True diff --git a/mrp_lot_number_propagation/tests/test_mrp_production.py b/mrp_lot_number_propagation/tests/test_mrp_production.py new file mode 100644 index 000000000..fd16b1802 --- /dev/null +++ b/mrp_lot_number_propagation/tests/test_mrp_production.py @@ -0,0 +1,44 @@ +# Copyright 2022 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 lot number + cls.bom.lot_number_propagation = True + cls.line_tracked_by_sn.propagate_lot_number = True + 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_propagated_lot_producing(self): + self.assertTrue(self.order.is_lot_number_propagated) # set by onchange + self._update_stock_component_qty(self.order) + self.order.action_confirm() + self.assertTrue(self.order.is_lot_number_propagated) # set by action_confirm + self.assertTrue(any(self.order.move_raw_ids.mapped("propagate_lot_number"))) + self._set_qty_done(self.order) + self.assertEqual(self.order.propagated_lot_producing, self.LOT_NAME) + + def test_order_write_lot_producing_id_not_allowed(self): + with self.assertRaisesRegex(UserError, "not allowed"): + self.order.write({"lot_producing_id": False}) + + def test_order_post_inventory(self): + self._update_stock_component_qty(self.order) + self.order.action_confirm() + self._set_qty_done(self.order) + self.order.button_mark_done() + self.assertEqual(self.order.lot_producing_id.name, self.LOT_NAME) diff --git a/mrp_lot_number_propagation/views/mrp_bom.xml b/mrp_lot_number_propagation/views/mrp_bom.xml new file mode 100644 index 000000000..5f3f6f6a9 --- /dev/null +++ b/mrp_lot_number_propagation/views/mrp_bom.xml @@ -0,0 +1,28 @@ + + + + + + mrp.bom.form.inherit + mrp.bom + + + + + + + + + + + + + + diff --git a/mrp_lot_number_propagation/views/mrp_production.xml b/mrp_lot_number_propagation/views/mrp_production.xml new file mode 100644 index 000000000..62509a1dc --- /dev/null +++ b/mrp_lot_number_propagation/views/mrp_production.xml @@ -0,0 +1,25 @@ + + + + + + mrp.production.form.inherit + mrp.production + + + + + + + + + + +