Merge PR #922 into 15.0

Signed-off-by grindtildeath
This commit is contained in:
OCA-git-bot
2023-11-09 11:18:07 +00:00
18 changed files with 538 additions and 0 deletions

View File

@@ -0,0 +1 @@
To be auto generated

View File

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

View File

@@ -0,0 +1,24 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
{
"name": "MRP Production Inject Operation",
"summary": "Adds an existing operation from the Bill of Material",
"version": "15.0.1.0.0",
"development_status": "Beta",
"category": "Manufacturing",
"website": "https://github.com/OCA/manufacture",
"author": "Camptocamp, Odoo Community Association (OCA)",
"maintainers": ["grindtildeath"],
"license": "AGPL-3",
"application": False,
"installable": True,
"depends": [
"mrp_workorder_sequence",
],
"data": [
"security/ir.model.access.csv",
"views/mrp_production.xml",
"views/mrp_workorder.xml",
"wizard/mrp_workorder_injector.xml",
],
}

View File

@@ -0,0 +1,2 @@
from . import mrp_production
from . import mrp_workorder

View File

@@ -0,0 +1,72 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import api, fields, models
class MrpProduction(models.Model):
_inherit = "mrp.production"
display_inject_workorder = fields.Boolean(
compute="_compute_display_inject_workorder"
)
@api.depends("state", "bom_id", "bom_id.operation_ids", "workorder_ids")
def _compute_display_inject_workorder(self):
for production in self:
production.display_inject_workorder = (
production.state in ["confirmed", "progress", "to_close"]
and production.bom_id.operation_ids
and production.workorder_ids
)
def action_open_workorder_injector(self):
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"mrp_production_operation_injection.mrp_workorder_injector_action"
)
ctx = self.env.context.copy()
ctx["default_production_id"] = self.id
action.update({"context": ctx})
return action
def _prepare_injected_workorder_values(self, operation):
self.ensure_one()
return {
"name": operation.name,
"production_id": self.id,
"workcenter_id": operation.workcenter_id.id,
"product_uom_id": self.product_uom_id.id,
"operation_id": operation.id,
"state": "pending",
}
def _add_workorder(self, operation, previous_workorder):
self.ensure_one()
following_workorders = self.workorder_ids.filtered(
lambda w: w.sequence > previous_workorder.sequence
)
next_workorder = fields.first(following_workorders)
# Prepare creation of new workorder
workorder_values = self._prepare_injected_workorder_values(operation)
workorder_values["sequence"] = previous_workorder.sequence + 1
workorder_values["next_work_order_id"] = next_workorder.id
# FIXME: state computation is not good in Odoo anyway so handle
# only most 'probable' cases only
if next_workorder.state in ["ready", "progress"]:
workorder_values["state"] = "ready"
# Update following workorders sequence before create to make sure workorders
# can be ordered properly for _action_confirm (cf override in mrp_workorder)
for wo in following_workorders:
wo.sequence += 1
new_workorder = self.env["mrp.workorder"].create(workorder_values)
# Update next workorder
# FIXME: state computation is not good in Odoo anyway so handle
# only most 'probable' cases only
if next_workorder.state == "ready":
next_workorder.state = "pending"
new_workorder.duration_expected = new_workorder._get_duration_expected()
# Replan if needed after cache invalidation to make sure all workorders are considered
self.invalidate_cache()
if self.is_planned:
self._plan_workorders(replan=True)
return True

View File

@@ -0,0 +1,17 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import fields, models
class MrpWorkorder(models.Model):
_inherit = "mrp.workorder"
sequence = fields.Integer(readonly=True)
def _action_confirm(self):
# HACK: Ensure self is ordered according to redefined _order attribute
# in mrp_sequence module as _action_confirm needs to loop in this
# order to redefine next_work_order_id properly
self = self.sorted()
return super()._action_confirm()

View File

@@ -0,0 +1 @@
* Akim Juillerat <akim.juillerat@camptocamp.com>

View File

@@ -0,0 +1,2 @@
This module provides a wizard to add extra workorders based on existing BOM
operations, and to select where such workorder should be added.

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
mrp_production_operation_injection.access_mrp_workorder_injector,access_mrp_workorder_injector,mrp_production_operation_injection.model_mrp_workorder_injector,mrp.group_mrp_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 mrp_production_operation_injection.access_mrp_workorder_injector access_mrp_workorder_injector mrp_production_operation_injection.model_mrp_workorder_injector mrp.group_mrp_user 1 1 1 1

View File

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

View File

@@ -0,0 +1,262 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import fields
from odoo.tests.common import Form, TransactionCase
class TestMrpProductionInjectOperation(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.product_template_drawer = cls.env.ref(
"product.product_product_27_product_template"
)
cls.product_drawer = cls.env.ref("product.product_product_27")
cls.bom_drawer = cls.env.ref("mrp.mrp_bom_drawer_rout")
cls.packing_operation = cls.env.ref("mrp.mrp_routing_workcenter_4")
cls.testing_operation = cls.env.ref("mrp.mrp_routing_workcenter_3")
cls.assembly_operation = cls.env.ref("mrp.mrp_routing_workcenter_1")
cls.assembly_line_workcenter = cls.env.ref("mrp.mrp_workcenter_3")
cls.productive_time = cls.env.ref("mrp.block_reason7")
cls.color_attribute = cls.env.ref("product.product_attribute_2")
cls.color_att_value_white = cls.env.ref("product.product_attribute_value_3")
cls.color_att_value_black = cls.env.ref("product.product_attribute_value_4")
@classmethod
def _define_color_attribute_on_drawer(cls):
"""
Redefine Drawer product to manage attributes by:
- Creating white product
- Defining color as attribute of drawer product with white and black values
- Adding white component to the BOM drawer applying only on selected variant
- Adding a painting operation to the BOM drawer consuming white product only
on selected variant
"""
white_color_product_tmpl_form = Form(cls.env["product.template"])
white_color_product_tmpl_form.name = "White color"
white_color_product_tmpl = white_color_product_tmpl_form.save()
cls.env["product.template.attribute.line"].create(
{
"product_tmpl_id": cls.product_template_drawer.id,
"attribute_id": cls.color_attribute.id,
"value_ids": [
fields.Command.set(
[cls.color_att_value_white.id, cls.color_att_value_black.id]
)
],
}
)
tmpl_attr_value_white = cls.env["product.template.attribute.value"].search(
[
("product_tmpl_id", "=", cls.product_template_drawer.id),
("product_attribute_value_id", "=", cls.color_att_value_white.id),
]
)
tmpl_attr_value_black = cls.env["product.template.attribute.value"].search(
[
("product_tmpl_id", "=", cls.product_template_drawer.id),
("product_attribute_value_id", "=", cls.color_att_value_black.id),
]
)
cls.env["mrp.bom.line"].create(
{
"bom_id": cls.bom_drawer.id,
"product_id": white_color_product_tmpl.product_variant_id.id,
"bom_product_template_attribute_value_ids": [
fields.Command.link(tmpl_attr_value_white.id)
],
}
)
cls.env["mrp.routing.workcenter"].create(
{
"name": "Painting",
"bom_id": cls.bom_drawer.id,
"workcenter_id": cls.assembly_line_workcenter.id,
"bom_product_template_attribute_value_ids": [
fields.Command.link(tmpl_attr_value_white.id)
],
}
)
white_drawer = cls.product_template_drawer.product_variant_ids.filtered(
lambda p: p.product_template_variant_value_ids == tmpl_attr_value_white
)
black_drawer = cls.product_template_drawer.product_variant_ids.filtered(
lambda p: p.product_template_variant_value_ids == tmpl_attr_value_black
)
return white_drawer, black_drawer
@classmethod
def _create_manufacturing_order(cls, product, bom=None):
mo_form = Form(cls.env["mrp.production"])
mo_form.product_id = product
if bom is not None:
mo_form.bom_id = bom
mo = mo_form.save()
return mo
@classmethod
def _get_injector_wizard_form(cls, production):
action = production.action_open_workorder_injector()
injector_form = Form(
cls.env[action["res_model"]].with_context(**action.get("context", {}))
)
return injector_form
@classmethod
def _inject_operation(cls, production, new_operation, previous_workorder):
injector_form = cls._get_injector_wizard_form(production)
injector_form.operation_id = new_operation
injector_form.workorder_id = previous_workorder
injector_wiz = injector_form.save()
injector_wiz.action_add_operation()
@classmethod
def _get_new_workorder(cls, previously_existing_wos, existing_wos):
return previously_existing_wos - existing_wos
@classmethod
def _record_time_tracking(cls, workorder, duration, productivity):
workorder_form = Form(workorder)
with workorder_form.time_ids.new() as time_tracking_form:
time_tracking_form.date_end = fields.Datetime.add(
time_tracking_form.date_start, seconds=duration
)
time_tracking_form.loss_id = productivity
def test_injector_allowed_operations_no_variant(self):
"""Test only operations from bom are allowed in wizard"""
mo = self._create_manufacturing_order(self.product_drawer, self.bom_drawer)
mo.action_confirm()
injector_form = self._get_injector_wizard_form(mo)
non_related_bom_operations = self.env["mrp.routing.workcenter"].search(
[("id", "not in", self.bom_drawer.operation_ids.ids)]
)
self.assertTrue(non_related_bom_operations)
for op in non_related_bom_operations:
self.assertNotIn(op, injector_form.allowed_bom_operation_ids)
for op in mo.bom_id.operation_ids:
self.assertIn(op, injector_form.allowed_bom_operation_ids)
def test_injector_allowed_operations_variant(self):
"""Test only operations from bom are allowed in wizard"""
white_drawer, black_drawer = self._define_color_attribute_on_drawer()
mo = self._create_manufacturing_order(white_drawer, self.bom_drawer)
mo.action_confirm()
injector_form = self._get_injector_wizard_form(mo)
for op in mo.bom_id.operation_ids:
self.assertIn(op, injector_form.allowed_bom_operation_ids)
mo = self._create_manufacturing_order(black_drawer, self.bom_drawer)
mo.action_confirm()
injector_form = self._get_injector_wizard_form(mo)
for op in mo.bom_id.operation_ids:
if op.bom_product_template_attribute_value_ids:
self.assertEqual(op.name, "Painting")
self.assertNotIn(op, injector_form.allowed_bom_operation_ids)
else:
self.assertIn(op, injector_form.allowed_bom_operation_ids)
def test_injector_allowed_workorders_no_variant(self):
"""Test only workorders from manufacturing order are allowed in wizard"""
mo = self._create_manufacturing_order(self.product_drawer, self.bom_drawer)
mo.action_confirm()
injector_form = self._get_injector_wizard_form(mo)
non_mo_workorders = self.env["mrp.workorder"].search(
[("id", "not in", mo.workorder_ids.ids)]
)
for wo in non_mo_workorders:
self.assertNotIn(wo, injector_form.production_workorder_ids)
for wo in mo.workorder_ids:
self.assertIn(wo, injector_form.production_workorder_ids)
# Ensure only last done workorder is selectable as previous operation
first_workorder = fields.first(mo.workorder_ids)
second_workorder = first_workorder.next_work_order_id
third_workorder = second_workorder.next_work_order_id
first_workorder.button_start()
first_workorder.button_finish()
second_workorder.button_start()
second_workorder.button_finish()
injector_form = self._get_injector_wizard_form(mo)
self.assertNotIn(first_workorder, injector_form.production_workorder_ids)
self.assertIn(second_workorder, injector_form.production_workorder_ids)
self.assertIn(third_workorder, injector_form.production_workorder_ids)
def test_inject_operation(self):
mo = self._create_manufacturing_order(self.product_drawer, self.bom_drawer)
mo.action_confirm()
mo.button_plan()
first_workorder = fields.first(mo.workorder_ids)
second_workorder = first_workorder.next_work_order_id
third_workorder = second_workorder.next_work_order_id
# Inject extra testing operation at the end
self._inject_operation(mo, self.testing_operation, third_workorder)
self.assertEqual(len(mo.workorder_ids), 4)
last_workorder = fields.first(mo.workorder_ids.sorted(reverse=True))
self.assertEqual(last_workorder.name, self.testing_operation.name)
self.assertEqual(last_workorder.operation_id, self.testing_operation)
self.assertEqual(
last_workorder.workcenter_id, self.testing_operation.workcenter_id
)
self.assertEqual(last_workorder.state, "pending")
self.assertEqual(last_workorder.sequence, 4)
self.assertEqual(
last_workorder.date_planned_start, third_workorder.date_planned_finished
)
self.assertEqual(
last_workorder.date_planned_finished,
last_workorder.workcenter_id.resource_calendar_id.plan_hours(
last_workorder.duration_expected / 60.0,
last_workorder.date_planned_start,
compute_leaves=True,
domain=[("time_type", "in", ["leave", "other"])],
),
)
self.assertEqual(third_workorder.next_work_order_id, last_workorder)
# Start first op and register time tracking
first_workorder.button_start()
self._record_time_tracking(first_workorder, 60, self.productive_time)
first_workorder.button_finish()
self.assertEqual(first_workorder.state, "done")
self.assertEqual(second_workorder.state, "ready")
# Inject extra packing operation before second workorder
pre_existing_wo_ids = set(mo.workorder_ids.ids)
self._inject_operation(mo, self.packing_operation, first_workorder)
existing_wo_ids = set(mo.workorder_ids.ids)
new_workorder = self.env["mrp.workorder"].browse(
existing_wo_ids - pre_existing_wo_ids
)
self.assertEqual(new_workorder.state, "ready")
self.assertEqual(new_workorder.sequence, 2)
self.assertEqual(
new_workorder.date_planned_start,
new_workorder.workcenter_id.resource_calendar_id.plan_hours(
-new_workorder.duration_expected / 60.0,
new_workorder.date_planned_finished,
compute_leaves=True,
domain=[("time_type", "in", ["leave", "other"])],
),
)
self.assertEqual(
new_workorder.date_planned_finished, second_workorder.date_planned_start
)
self.assertEqual(new_workorder.next_work_order_id, second_workorder)
# Second workorder is now the third one
self.assertEqual(second_workorder.state, "pending")
self.assertEqual(second_workorder.sequence, 3)
self.assertEqual(second_workorder.next_work_order_id, third_workorder)
# Third workorder is now the fourth one
self.assertEqual(third_workorder.state, "pending")
self.assertEqual(third_workorder.sequence, 4)
self.assertEqual(third_workorder.next_work_order_id, last_workorder)
# Last workorder is still the last one
self.assertEqual(last_workorder.state, "pending")
self.assertEqual(last_workorder.sequence, 5)
self.assertFalse(last_workorder.next_work_order_id)

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="mrp_production_form_view_inherit" 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">
<header position="inside">
<field name="display_inject_workorder" invisible="1" />
<button
name="action_open_workorder_injector"
type="object"
string="Inject operation"
attrs="{'invisible': [('display_inject_workorder', '=', False)]}"
/>
</header>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="mrp_production_workorder_tree_view_inherit" model="ir.ui.view">
<field name="name">mrp.workorder.tree</field>
<field name="model">mrp.workorder</field>
<field
name="inherit_id"
ref="mrp_workorder_sequence.mrp_production_workorder_tree_view_inherit"
/>
<field name="arch" type="xml">
<field name="sequence" position="attributes">
<attribute name="force_save">1</attribute>
</field>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,67 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import api, fields, models
class MrpWorkorderInjector(models.TransientModel):
_name = "mrp.workorder.injector"
_description = "Inject operation from BOM into workorders"
production_id = fields.Many2one("mrp.production", required=True)
bom_id = fields.Many2one("mrp.bom", related="production_id.bom_id")
allowed_bom_operation_ids = fields.Many2many(
"mrp.routing.workcenter", compute="_compute_allowed_bom_operation_ids"
)
production_workorder_ids = fields.Many2many(
"mrp.workorder", compute="_compute_production_workorder_ids"
)
operation_id = fields.Many2one(
"mrp.routing.workcenter", "New operation", required=True
)
workorder_id = fields.Many2one("mrp.workorder", "Previous workorder", required=True)
@api.depends("bom_id", "bom_id.operation_ids")
def _compute_allowed_bom_operation_ids(self):
for wiz in self:
bom_operations = wiz.bom_id.operation_ids
# TODO: Move this check in default_get or somewhere else?
if not wiz.bom_id or not bom_operations:
wiz.allowed_bom_operation_ids = [fields.Command.clear()]
continue
# Filter out operations applying only for other variants
allowed_operations = bom_operations.filtered(
lambda o: not o._skip_operation_line(wiz.production_id.product_id)
)
# TODO: filter out operations consuming components?
# AFAICS the link from bom line to operations will only be used to define
# on the stock move in which workorder such component is supposed to be
# consumed, and will then be used to compute the state of the workorder
# through the reservation_state field of the MO:
# - Waiting components if move is not assigned
# - Ready if move is assigned
wiz.allowed_bom_operation_ids = [fields.Command.set(allowed_operations.ids)]
@api.depends("production_id")
def _compute_production_workorder_ids(self):
for wiz in self:
prod_workorders = wiz.production_id.workorder_ids
if not prod_workorders:
wiz.production_workorder_ids = [fields.Command.clear()]
continue
done_wos = prod_workorders.filtered(lambda w: w.state == "done")
if not done_wos or len(done_wos) == 1:
wiz.production_workorder_ids = [fields.Command.set(prod_workorders.ids)]
continue
# Only allow to add new operation after last Done workorder
last_done_wo = fields.first(done_wos.sorted(reverse=True))
allowed_wos = last_done_wo + prod_workorders.filtered(
lambda w: w.state != "done"
)
wiz.production_workorder_ids = allowed_wos
def action_add_operation(self):
self.ensure_one()
self.production_id._add_workorder(self.operation_id, self.workorder_id)
return {"type": "ir.actions.act_window_close"}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="mrp_workorder_injector_view_form" model="ir.ui.view">
<field name="name">mrp.workorder.injector.form.view</field>
<field name="model">mrp.workorder.injector</field>
<field name="arch" type="xml">
<form string="Add operation to workorders">
<group>
<field name="production_id" invisible="1" />
<field name="bom_id" invisible="1" />
<field name="allowed_bom_operation_ids" invisible="1" />
<field name="production_workorder_ids" invisible="1" />
<field
name="operation_id"
domain="[('id', 'in', allowed_bom_operation_ids)]"
options="{'no_create': True, 'no_quick_create': True, 'no_create_edit':True}"
/>
<field
name="workorder_id"
domain="[('id', 'in', production_workorder_ids)]"
options="{'no_create': True, 'no_quick_create': True, 'no_create_edit':True}"
/>
</group>
<footer>
<button
name="action_add_operation"
type="object"
string="Add"
class="oe_highlight"
/>
<button special="cancel" string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="mrp_workorder_injector_action" model="ir.actions.act_window">
<field name="name">Inject operation</field>
<field name="res_model">mrp.workorder.injector</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
../../../../mrp_production_operation_injection

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)