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