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..b92e6ec25
--- /dev/null
+++ b/mrp_lot_number_propagation/models/__init__.py
@@ -0,0 +1,6 @@
+from . import mrp_bom
+from . import mrp_bom_line
+from . import mrp_production
+from . import product_product
+from . import product_template
+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..a084dcf12
--- /dev/null
+++ b/mrp_lot_number_propagation/models/mrp_bom.py
@@ -0,0 +1,88 @@
+# Copyright 2022 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 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
+
+ @api.constrains("lot_number_propagation")
+ def _check_propagate_lot_number(self):
+ for bom in self:
+ if not bom.lot_number_propagation:
+ continue
+ if not bom.bom_line_ids.filtered("propagate_lot_number"):
+ raise ValidationError(
+ _(
+ "With 'Lot Number Propagation' enabled, a line has "
+ "to be configured with the 'Propagate Lot Number' option."
+ )
+ )
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..b8b02a403
--- /dev/null
+++ b/mrp_lot_number_propagation/models/mrp_bom_line.py
@@ -0,0 +1,58 @@
+# 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")
+ 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:
+ if not line.bom_id.lot_number_propagation:
+ continue
+ lines_to_propagate = line.bom_id.bom_line_ids.filtered(
+ lambda o: o.propagate_lot_number
+ )
+ 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/product_product.py b/mrp_lot_number_propagation/models/product_product.py
new file mode 100644
index 000000000..69c72b19d
--- /dev/null
+++ b/mrp_lot_number_propagation/models/product_product.py
@@ -0,0 +1,13 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from odoo import api, models
+
+
+class ProductProduct(models.Model):
+ _inherit = "product.product"
+
+ @api.constrains("tracking")
+ def _check_bom_propagate_lot_number(self):
+ for product in self:
+ product.product_tmpl_id._check_bom_propagate_lot_number()
diff --git a/mrp_lot_number_propagation/models/product_template.py b/mrp_lot_number_propagation/models/product_template.py
new file mode 100644
index 000000000..7a42adbb5
--- /dev/null
+++ b/mrp_lot_number_propagation/models/product_template.py
@@ -0,0 +1,42 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from odoo import _, api, models
+from odoo.exceptions import ValidationError
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ @api.constrains("tracking")
+ def _check_bom_propagate_lot_number(self):
+ """Block tracking type updates if the product is used by a BoM."""
+ for product in self:
+ if product.tracking == "serial":
+ continue
+ # Check BoMs
+ for bom in product.bom_ids:
+ if bom.lot_number_propagation:
+ raise ValidationError(
+ _(
+ "A BoM propagating serial numbers requires "
+ "this product to be tracked as such."
+ )
+ )
+ # Check lines of BoMs
+ bom_lines = self.env["mrp.bom.line"].search(
+ [
+ ("product_id", "in", product.product_variant_ids.ids),
+ ("propagate_lot_number", "=", True),
+ ("bom_id.lot_number_propagation", "=", True),
+ ]
+ )
+ if bom_lines:
+ boms = "\n- ".join(bom_lines.mapped("bom_id.display_name"))
+ boms = "\n- " + boms
+ raise ValidationError(
+ _(
+ "This component is configured to propagate its "
+ "serial number in the following Bill of Materials:{boms}'"
+ ).format(boms=boms)
+ )
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..18a4604f7
--- /dev/null
+++ b/mrp_lot_number_propagation/tests/test_mrp_bom.py
@@ -0,0 +1,83 @@
+# Copyright 2022 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_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):
+ form = Form(self.bom)
+ form.lot_number_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_lot_number = True
+ line_form.save()
+ with self.assertRaisesRegex(ValidationError, "Only one BoM"):
+ form.save()
+
+ def test_bom_line_check_propagate_lot_number_not_tracked(self):
+ form = Form(self.bom)
+ form.lot_number_propagation = True
+ # Flag a line that can't be propagated
+ line_form = form.bom_line_ids.edit(2) # line without tracking
+ line_form.propagate_lot_number = True
+ line_form.save()
+ with self.assertRaisesRegex(ValidationError, "Only components tracked"):
+ form.save()
+
+ def test_bom_line_check_propagate_lot_number_tracked_by_lot(self):
+ form = Form(self.bom)
+ form.lot_number_propagation = True
+ # Flag a line tracked by lot (not SN) which is not supported
+ line_form = form.bom_line_ids.edit(1)
+ line_form.propagate_lot_number = True
+ line_form.save()
+ with self.assertRaisesRegex(ValidationError, "Only components tracked"):
+ form.save()
+
+ def test_bom_line_check_propagate_lot_number_same_tracking(self):
+ form = Form(self.bom)
+ form.lot_number_propagation = True
+ # Flag a line whose tracking type is the same than the finished product
+ line_form = form.bom_line_ids.edit(0)
+ line_form.propagate_lot_number = True
+ line_form.save()
+ form.save()
+
+ def test_bom_check_propagate_lot_number(self):
+ # Configure the BoM to propagate the lot/SN without enabling any line
+ with self.assertRaisesRegex(ValidationError, "a line has to be configured"):
+ self.bom.lot_number_propagation = True
+
+ def test_reset_tracking_on_bom_product(self):
+ # Configure the BoM to propagate the lot/SN
+ with Form(self.bom) as form:
+ form.lot_number_propagation = True
+ line_form = form.bom_line_ids.edit(0) # Line tracked by SN
+ line_form.propagate_lot_number = True
+ line_form.save()
+ form.save()
+ # Reset the tracking on the finished product
+ with self.assertRaisesRegex(ValidationError, "A BoM propagating"):
+ self.bom.product_tmpl_id.tracking = "none"
+
+ def test_reset_tracking_on_bom_component(self):
+ # Configure the BoM to propagate the lot/SN
+ with Form(self.bom) as form:
+ form.lot_number_propagation = True
+ line_form = form.bom_line_ids.edit(0) # Line tracked by SN
+ line_form.propagate_lot_number = True
+ line_form.save()
+ form.save()
+ # Reset the tracking on the component which propagates the SN
+ with self.assertRaisesRegex(ValidationError, "This component is"):
+ self.line_tracked_by_sn.product_id.tracking = "none"
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..ffbad6446
--- /dev/null
+++ b/mrp_lot_number_propagation/tests/test_mrp_production.py
@@ -0,0 +1,48 @@
+# 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
+ with Form(cls.bom) as form:
+ form.lot_number_propagation = True
+ line_form = form.bom_line_ids.edit(0) # Line tracked by SN
+ line_form.propagate_lot_number = 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_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
+
+
+
+
+
+
+
+
+
+
+
diff --git a/setup/mrp_lot_number_propagation/odoo/addons/mrp_lot_number_propagation b/setup/mrp_lot_number_propagation/odoo/addons/mrp_lot_number_propagation
new file mode 120000
index 000000000..d7a58299b
--- /dev/null
+++ b/setup/mrp_lot_number_propagation/odoo/addons/mrp_lot_number_propagation
@@ -0,0 +1 @@
+../../../../mrp_lot_number_propagation
\ No newline at end of file
diff --git a/setup/mrp_lot_number_propagation/setup.py b/setup/mrp_lot_number_propagation/setup.py
new file mode 100644
index 000000000..28c57bb64
--- /dev/null
+++ b/setup/mrp_lot_number_propagation/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)