[ADD] mrp_lot_number_propagation

This commit is contained in:
Sébastien Alix
2022-09-07 10:43:54 +02:00
committed by Thierry Ducrest
parent 2a303f875a
commit f1e4bfe52c
17 changed files with 557 additions and 0 deletions

View File

@@ -0,0 +1 @@
.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -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,
}

View File

@@ -0,0 +1,4 @@
from . import mrp_bom
from . import mrp_bom_line
from . import mrp_production
from . import stock_move

View File

@@ -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

View File

@@ -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."
)
)

View File

@@ -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 <div>
)
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")

View File

@@ -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,
)

View File

@@ -0,0 +1,2 @@
* Akim Juillerat <akim.juillerat@camptocamp.com>
* Sébastien Alix <sebastien.alix@camptocamp.com>

View File

@@ -0,0 +1 @@
Allow to propagate a lot number from a component to a finished product.

View File

@@ -0,0 +1 @@
* Add compatibility with lot number (in addition to serial number)

View File

@@ -0,0 +1 @@
from . import test_mrp_bom

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2022 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="mrp_bom_form_view" model="ir.ui.view">
<field name="name">mrp.bom.form.inherit</field>
<field name="model">mrp.bom</field>
<field name="inherit_id" ref="mrp.mrp_bom_form_view" />
<field name="arch" type="xml">
<field name="company_id" position="after">
<field name="display_lot_number_propagation" invisible="1" />
<field
name="lot_number_propagation"
attrs="{'invisible': [('display_lot_number_propagation', '=', False)]}"
/>
</field>
<xpath expr="//field[@name='bom_line_ids']/tree" position="inside">
<field name="display_propagate_lot_number" invisible="1" />
<field
name="propagate_lot_number"
attrs="{'column_invisible': ['|', ('parent.display_lot_number_propagation', '=', False), ('parent.lot_number_propagation', '=', False)]}"
/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2022 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="mrp_production_form_view" model="ir.ui.view">
<field name="name">mrp.production.form.inherit</field>
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view" />
<field name="arch" type="xml">
<!-- Place new fields in the first group while being compatible with OE -->
<xpath expr="//field[@name='id']/.." position="inside">
<field name="is_lot_number_propagated" force_save="1" />
</xpath>
<label for="lot_producing_id" position="before">
<field
name="propagated_lot_producing"
string="Lot/Serial Number"
attrs="{'invisible': [('is_lot_number_propagated', '=', False)]}"
/>
</label>
</field>
</record>
</odoo>