[IMP] mrp: MO/WO UI Improvements

Improve the UI for the MO and the WO with some new features, better readability and usability

- The forecast for the MO in draft makes the preparation of a MO easier. The behavior of `forecast_availability` depend of the state of the move
- The sum of expected duration and real duration in the MO, make the MO visually clearer
- The field Scheduled Date is rounding up to the next hour for less manipulation
- The automatic reference in a BOM if there is already a BOM for this product allows making less manipulation. I'm using this variable `number_of_bom_of_this_product` with the result of the query inside so I didn't need to do the query twice and the query doesn't take the current bom.
- A rounded value is better for the OEE in WO summary
- Have a tag for a workcenter can be a good idea to add more information to it. I'm using a random value for the color of the tag, we are doing it frequently to have a random color on multiple think.
- Knowing the final product of a WO is clearer
- To notify the user when creating a product with a reference that are already existing is a good idea.
- Change some color in the MO view for better readability
- And more minor tweak (like to align some part in the view)

--- Links ---
The PR: https://github.com/odoo/odoo/pull/74540
The PR on enterprise: https://github.com/odoo/enterprise/pull/20006
Task related: 2442895

closes odoo/odoo#74540

Related: odoo/enterprise#20006
Signed-off-by: Rémy Voet <ryv-odoo@users.noreply.github.com>
This commit is contained in:
brsy-odoo
2021-08-25 07:50:36 +00:00
committed by Bruno-brsy
parent fe161c2105
commit 281e1a57b3
13 changed files with 121 additions and 30 deletions
+11
View File
@@ -141,6 +141,15 @@ class MrpBom(models.Model):
self.operation_ids.bom_product_template_attribute_value_ids = False
self.byproduct_ids.bom_product_template_attribute_value_ids = False
domain = [('product_tmpl_id', '=', self.product_tmpl_id.id)]
if self.id.origin:
domain.append(('id', '!=', self.id.origin))
number_of_bom_of_this_product = self.env['mrp.bom'].search_count(domain)
if number_of_bom_of_this_product: # add a reference to the bom if there is already a bom for this product
self.code = _("%s (new) %s", self.product_tmpl_id.name, number_of_bom_of_this_product)
else:
self.code = False
def copy(self, default=None):
res = super().copy(default)
for bom_line in res.bom_line_ids:
@@ -446,6 +455,7 @@ class MrpByProduct(models.Model):
_description = 'Byproduct'
_rec_name = "product_id"
_check_company_auto = True
_order = 'sequence, id'
product_id = fields.Many2one('product.product', 'By-product', required=True, check_company=True)
company_id = fields.Many2one(related='bom_id.company_id', store=True, index=True, readonly=True)
@@ -465,6 +475,7 @@ class MrpByProduct(models.Model):
'product.template.attribute.value', string="Apply on Variants", ondelete='restrict',
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
help="BOM Product Variants needed to apply this line.")
sequence = fields.Integer("Sequence")
@api.onchange('product_id')
def _onchange_product_id(self):
+15 -1
View File
@@ -65,7 +65,7 @@ class MrpProduction(models.Model):
def _get_default_date_planned_start(self):
if self.env.context.get('default_date_deadline'):
return fields.Datetime.to_datetime(self.env.context.get('default_date_deadline'))
return datetime.datetime.now()
return fields.Datetime.now().replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
@api.model
def _get_default_is_locked(self):
@@ -143,6 +143,10 @@ class MrpProduction(models.Model):
help="Informative date allowing to define when the manufacturing order should be processed at the latest to fulfill delivery on time.")
date_start = fields.Datetime('Start Date', copy=False, readonly=True, help="Date of the WO")
date_finished = fields.Datetime('End Date', copy=False, readonly=True, help="Date when the MO has been close")
production_duration_expected = fields.Float("Expected Duration", help="Total expected duration (in minutes)", compute='_compute_production_duration_expected')
production_real_duration = fields.Float("Real Duration", help="Total real duration (in minutes)", compute='_compute_production_real_duration')
bom_id = fields.Many2one(
'mrp.bom', 'Bill of Material',
readonly=True, states={'draft': [('readonly', False)]},
@@ -308,6 +312,16 @@ class MrpProduction(models.Model):
for production in self:
production.move_finished_ids.date_deadline = production.date_deadline
@api.depends('workorder_ids.duration_expected')
def _compute_production_duration_expected(self):
for production in self:
production.production_duration_expected = sum(production.workorder_ids.mapped('duration_expected'))
@api.depends('workorder_ids.duration')
def _compute_production_real_duration(self):
for production in self:
production.production_real_duration = sum(production.workorder_ids.mapped('duration'))
@api.depends("workorder_ids.date_planned_start", "workorder_ids.date_planned_finished")
def _compute_is_planned(self):
for production in self:
+19
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
from functools import partial
import datetime
from pytz import timezone
from random import randint
from odoo import api, exceptions, fields, models, _
from odoo.exceptions import ValidationError
@@ -71,6 +72,7 @@ class MrpWorkcenter(models.Model):
string="Alternative Workcenters", check_company=True,
help="Alternative workcenters that can be substituted to this one in order to dispatch production"
)
tag_ids = fields.Many2many('mrp.workcenter.tag')
@api.constrains('alternative_workcenter_ids')
def _check_alternative_workcenter(self):
@@ -264,6 +266,23 @@ class MrpWorkcenter(models.Model):
return False, 'Not available slot 700 days after the planned start'
class WorkcenterTag(models.Model):
_name = 'mrp.workcenter.tag'
_description = 'Add tag for the workcenter'
_order = 'name'
def _get_default_color(self):
return randint(1, 11)
name = fields.Char("Tag Name", required=True)
color = fields.Integer("Color Index", default=_get_default_color)
_sql_constraints = [
('tag_name_unique', 'unique(name)',
'The tag name must be unique.'),
]
class MrpWorkcenterProductivityLossType(models.Model):
_name = "mrp.workcenter.productivity.loss.type"
_description = 'MRP Workorder productivity losses'
+2
View File
@@ -63,3 +63,5 @@ access_mrp_consumption_warning,access.mrp.consumption.warning,model_mrp_consumpt
access_mrp_consumption_warning_line,access.mrp.consumption.warning.line,model_mrp_consumption_warning_line,mrp.group_mrp_user,1,1,1,0
access_mrp_immediate_production,access.mrp.immediate.production,model_mrp_immediate_production,mrp.group_mrp_user,1,1,1,0
access_mrp_immediate_production_line,access.mrp.immediate.production.line,model_mrp_immediate_production_line,mrp.group_mrp_user,1,1,1,0
access_mrp_workcenter_tag_group_user,access.mrp.workcenter.tag,model_mrp_workcenter_tag,mrp.group_mrp_user,1,0,0,0
access_mrp_workcenter_tag_manager,access.mrp.workcenter.tag,model_mrp_workcenter_tag,mrp.group_mrp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
63 access_mrp_consumption_warning_line access.mrp.consumption.warning.line model_mrp_consumption_warning_line mrp.group_mrp_user 1 1 1 0
64 access_mrp_immediate_production access.mrp.immediate.production model_mrp_immediate_production mrp.group_mrp_user 1 1 1 0
65 access_mrp_immediate_production_line access.mrp.immediate.production.line model_mrp_immediate_production_line mrp.group_mrp_user 1 1 1 0
66 access_mrp_workcenter_tag_group_user access.mrp.workcenter.tag model_mrp_workcenter_tag mrp.group_mrp_user 1 0 0 0
67 access_mrp_workcenter_tag_manager access.mrp.workcenter.tag model_mrp_workcenter_tag mrp.group_mrp_manager 1 1 1 1
+1
View File
@@ -116,6 +116,7 @@
<tree string="By-products" editable="top">
<field name="company_id" invisible="1"/>
<field name="product_uom_category_id" invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="product_id" context="{'default_type': 'product'}"/>
<field name="product_qty"/>
<field name="product_uom_id" groups="uom.group_uom"/>
+10 -5
View File
@@ -17,13 +17,15 @@
<field name="date_planned_start" readonly="1" optional="show" widget="remaining_days"/>
<field name="date_deadline" widget="remaining_days" attrs="{'invisible': [('state', 'in', ['done', 'cancel'])]}" optional="hide"/>
<field name="product_id" readonly="1" optional="show"/>
<field name="product_uom_id" string="Unit of Measure" options="{'no_open':True,'no_create':True}" groups="uom.group_uom" optional="show"/>
<field name="production_duration_expected" attrs="{'invisible': [('production_duration_expected', '=', 0)]}" widget="float_time" sum="Total expected duration"/>
<field name="production_real_duration" attrs="{'invisible': [('production_real_duration', '=', 0)]}" widget="float_time" sum="Total real duration"/>
<field name="lot_producing_id" optional="hide"/>
<field name="bom_id" readonly="1" optional="hide"/>
<field name="origin" optional="show"/>
<field name="user_id" optional="hide" widget="many2one_avatar_user"/>
<field name="reservation_state" optional="show"/>
<field name="reservation_state" optional="show" decoration-danger="reservation_state == 'confirmed'" decoration-success="reservation_state == 'assigned'"/>
<field name="product_qty" sum="Total Qty" string="Quantity" readonly="1" optional="show"/>
<field name="product_uom_id" string="UoM" options="{'no_open':True,'no_create':True}" groups="uom.group_uom" optional="show"/>
<field name="company_id" readonly="1" groups="base.group_multi_company" optional="show"/>
<field name="state" optional="show" widget='badge' decoration-success="state == 'done'" decoration-info="state not in ('done', 'cancel')"/>
<field name="activity_exception_decoration" widget="activity_exception"/>
@@ -154,7 +156,7 @@
<field name="use_create_components_lots" invisible="1"/>
<field name="show_lot_ids" invisible="1"/>
<field name="product_tracking" invisible="1"/>
<field name="product_id" context="{'default_type': 'product'}" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
<field name="product_id" context="{'default_type': 'product'}" attrs="{'readonly': [('state', '!=', 'draft')]}" default_focus="1"/>
<field name="product_tmpl_id" invisible="1"/>
<field name="forecasted_issue" invisible="1"/>
<field name="product_description_variants" attrs="{'invisible': [('product_description_variants', 'in', (False, ''))], 'readonly': [('state', '!=', 'draft')]}"/>
@@ -276,17 +278,20 @@
<field name="state" invisible="1" force_save="1"/>
<field name="should_consume_qty" invisible="1"/>
<field name="product_uom_qty" widget="mrp_should_consume" string="To Consume" attrs="{'readonly': ['&amp;', ('parent.state', '!=', 'draft'), '|', '&amp;', ('parent.state', 'not in', ('confirmed', 'progress', 'to_close')), ('parent.is_planned', '!=', True), ('parent.is_locked', '=', True)]}" width="1"/>
<field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft')]}" options="{'no_open': True, 'no_create': True}" groups="uom.group_uom"/>
<field name="product_type" invisible="1"/>
<field name="product_qty" invisible="1" readonly="1"/>
<field name="reserved_availability" invisible="1"/>
<field name="forecast_expected_date" invisible="1"/>
<field name="forecast_availability" string="Reserved" attrs="{'column_invisible': [('parent.state', 'in', ['draft', 'done'])]}" widget="forecast_widget"/>
<!-- Button are used in state draft to doesn't have the name of the column "Reserved"-->
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'column_invisible': [('parent.state', '!=', 'draft')], 'invisible': [('forecast_availability', '&lt;', 0)]}"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart text-danger" attrs="{'column_invisible': [('parent.state', '!=', 'draft')], 'invisible': [('forecast_availability', '&gt;=', 0)]}"/>
<field name="forecast_availability" string="Reserved" attrs="{'column_invisible': [('parent.state', 'in', ('draft', 'done'))]}" widget="forecast_widget"/>
<field name="is_quantity_done_editable" invisible="1"/>
<field name="quantity_done" string="Consumed"
decoration-success="not is_done and (quantity_done - should_consume_qty == 0)"
decoration-warning="not is_done and (quantity_done - should_consume_qty &gt; 0.0001)"
attrs="{'column_invisible': [('parent.state', '=', 'draft')], 'readonly': [('has_tracking', '!=','none')]}"/>
<field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft')]}" options="{'no_open': True, 'no_create': True}" groups="uom.group_uom"/>
<field name="show_details_visible" invisible="1"/>
<field name="lot_ids" widget="many2many_tags"
groups="stock.group_production_lot" optional="hide"
+8 -6
View File
@@ -36,15 +36,16 @@
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="alternative_workcenter_ids" widget="many2many_tags" optional="hide"/>
<field name="tag_ids" widget="many2many_tags" options="{'no_create': True, 'color_field': 'color'}" optional="show"/>
<field name="alternative_workcenter_ids" widget="many2many_tags" optional="show"/>
<field name="productive_time" optional="hide"/>
<field name="costs_hour"/>
<field name="capacity"/>
<field name="time_efficiency" optional="hide"/>
<field name="oee_target" optional="hide"/>
<field name="time_efficiency" optional="show"/>
<field name="oee_target" optional="show"/>
<field name="time_start" optional="hide"/>
<field name="time_stop" optional="hide"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
<field name="active" invisible="1"/>
</tree>
</field>
@@ -223,7 +224,7 @@
<div class="col-6 text-right">
<span t-att-class="record.oee_target.raw_value and (record.oee.raw_value &lt; record.oee_target.raw_value) and 'text-danger' or (record.oee.raw_value &gt; record.oee_target.raw_value) and 'text-success' or 'text-warning'">
<strong>
<field name="oee"/>%
<field name="oee" widget="integer"/>%
</strong>
</span>
</div>
@@ -278,7 +279,7 @@
<div role="menuitem" aria-haspopup="true" class="col-8">
<ul role="menu" class="oe_kanban_colorpicker" data-field="color"/>
</div>
<div role="menuitem" class="col-4 text-right">
<div role="menuitem" class="text-right">
<a type="edit">Settings</a>
</div>
</div>
@@ -333,6 +334,7 @@
<group>
<field name="active" invisible="1"/>
<field name="name" string="Work Center Name" required="True"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field
name="alternative_workcenter_ids"
widget="many2many_tags"
+6 -5
View File
@@ -76,13 +76,14 @@
<field name="operation_id" invisible="1" domain="['|', ('bom_id', '=', production_bom_id), ('bom_id', '=', False)]" context="{'default_workcenter_id': workcenter_id, 'default_company_id': company_id}"/>
<field name="name" string="Operation"/>
<field name="workcenter_id"/>
<field name="product_id" optional="show"/>
<field name="date_planned_start" optional="show"/>
<field name="date_planned_finished" optional="hide"/>
<field name="date_start" optional="hide" readonly="1"/>
<field name="date_finished" optional="hide" readonly="1"/>
<field name="duration_expected" widget="float_time"/>
<field name="duration_expected" widget="float_time" sum="expected duration"/>
<field name="duration" widget="mrp_time_counter"
attrs="{'invisible': [('production_state','=', 'draft')], 'readonly': [('is_user_working', '=', True)]}"/>
attrs="{'invisible': [('production_state','=', 'draft')], 'readonly': [('is_user_working', '=', True)]}" sum="real duration"/>
<field name="state" widget="badge" decoration-warning="state == 'waiting'" decoration-success="state == 'done'" decoration-info="state not in ('waiting', 'done', 'cancel')"
attrs="{'invisible': [('production_state', '=', 'draft')], 'column_invisible': [('parent.state', '=', 'draft')]}"/>
<button name="button_start" type="object" string="Start" class="btn-success"
@@ -369,19 +370,19 @@
<div class="oe_kanban_global_click">
<div class="o_kanban_card_header o_kanban_record_top">
<div class="o_kanban_record_headings o_kanban_card_header_title">
<strong class="o_primary ml8">
<strong class="o_primary">
<span><t t-esc="record.production_id.value"/></span> - <span><t t-esc="record.name.value"/></span>
</strong>
</div>
<div class="o_kanban_manage_button_section">
<h2>
<h2 class="ml8">
<span t-attf-class="badge #{['pending', 'waiting'].indexOf(record.state.raw_value) > -1 ? 'badge-warning' :['progress'].indexOf(record.state.raw_value) > -1 ? 'badge-secondary' : ['ready'].indexOf(record.state.raw_value) > -1 ? 'badge-primary' : ['done'].indexOf(record.state.raw_value) > -1 ? 'badge-success' : ['cancel'].indexOf(record.state.raw_value) > -1 ? 'badge-muted' : 'badge-danger'}">
<t t-esc="record.state.value"/>
</span>
</h2>
</div>
</div>
<div class="container o_kanban_record_bottom">
<div class="o_kanban_record_bottom">
<h5 class="oe_kanban_bottom_left">
<span><t t-esc="record.product_id.value"/>, </span> <span><t t-esc="record.qty_production.value"/> <t t-esc="record.product_uom_id.value"/></span>
</h5>
@@ -38,8 +38,8 @@
colspan="1" type="object" class="btn-primary"/>
<button name="action_confirm" string="Confirm" attrs="{'invisible': [('consumption', '=', 'strict')]}" data-hotkey="q"
colspan="1" type="object" class="btn-primary"/>
<button name="action_cancel" string="Review Consumption" data-hotkey="w"
colspan="1" type="object" class="btn-primary"/>
<button name="action_cancel" string="Discard" data-hotkey="w"
colspan="1" type="object" class="btn-secondary"/>
</footer>
</form>
</field>
+15
View File
@@ -337,6 +337,21 @@ class ProductProduct(models.Model):
if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id:
self.uom_po_id = self.uom_id
@api.onchange('default_code')
def _onchange_default_code(self):
if not self.default_code:
return
domain = [('default_code', '=', self.default_code)]
if self.id.origin:
domain.append(('id', '!=', self.id.origin))
if self.env['product.product'].search(domain, limit=1):
return {'warning': {
'title': _("Note:"),
'message': _("The Internal Reference '%s' already exists.", self.default_code),
}}
@api.model_create_multi
def create(self, vals_list):
products = super(ProductProduct, self.with_context(create_product_product=True)).create(vals_list)
+15
View File
@@ -347,6 +347,21 @@ class ProductTemplate(models.Model):
for template in self:
template.product_variant_count = len(template.product_variant_ids)
@api.onchange('default_code')
def _onchange_default_code(self):
if not self.default_code:
return
domain = [('default_code', '=', self.default_code)]
if self.id.origin:
domain.append(('id', '!=', self.id.origin))
if self.env['product.template'].search(domain, limit=1):
return {'warning': {
'title': _("Note:"),
'message': _("The Internal Reference '%s' already exists.", self.default_code),
}}
@api.depends('product_variant_ids', 'product_variant_ids.default_code')
def _compute_default_code(self):
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
+16 -10
View File
@@ -64,7 +64,7 @@ class StockMove(models.Model):
"be moved. Lowering this quantity does not generate a "
"backorder. Changing this quantity on assigned moves affects "
"the product reservation, and should be done with care.")
product_uom = fields.Many2one('uom.uom', 'Unit of Measure', required=True, domain="[('category_id', '=', product_uom_category_id)]")
product_uom = fields.Many2one('uom.uom', "UoM", required=True, domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
# TDE FIXME: make it stored, otherwise group will not work
product_tmpl_id = fields.Many2one(
@@ -418,14 +418,19 @@ class StockMove(models.Model):
outgoing_unreserved_moves_per_warehouse = defaultdict(lambda: self.env['stock.move'])
for move in product_moves:
is_unreserved = move.state in ('waiting', 'confirmed', 'partially_available')
if move.picking_type_id.code in self._consuming_picking_types() and is_unreserved:
outgoing_unreserved_moves_per_warehouse[warehouse_by_location[move.location_id]] |= move
elif move.picking_type_id.code in self._consuming_picking_types():
move.forecast_availability = move.product_uom._compute_quantity(
move.reserved_availability, move.product_id.uom_id, rounding_method='HALF-UP')
if move.picking_type_id.code in self._consuming_picking_types():
if move.state == 'assigned':
move.forecast_availability = move.product_uom._compute_quantity(
move.reserved_availability, move.product_id.uom_id, rounding_method='HALF-UP')
elif move.state == 'draft':
# for move _consuming_picking_types and in draft -> the forecast_availability > 0 if in stock
warehouse = move.location_id.warehouse_id
next_date = max(move.date, fields.Datetime.now())
move.forecast_availability = move.product_id.with_context(warehouse=warehouse.id, to_date=next_date).virtual_available - move.product_qty
elif move.state in ('waiting', 'confirmed', 'partially_available'):
outgoing_unreserved_moves_per_warehouse[warehouse_by_location[move.location_id]] |= move
elif move.picking_type_id.code == 'incoming':
move._get_forecast_availability_incoming()
move.forecast_availability = move._get_forecast_availability_incoming()
for warehouse, moves in outgoing_unreserved_moves_per_warehouse.items():
if not warehouse: # No prediction possible if no warehouse.
@@ -909,9 +914,10 @@ class StockMove(models.Model):
def _get_forecast_availability_incoming(self):
self.ensure_one()
warehouse = self.location_dest_id.warehouse_id
self.forecast_availability = self.product_id.with_context(warehouse=warehouse.id, to_date=self.date).virtual_available
forecast_availability = self.product_id.with_context(warehouse=warehouse.id, to_date=self.date).virtual_available
if self.state == 'draft':
self.forecast_availability += self.product_uom_qty
forecast_availability += self.product_uom_qty
return forecast_availability
@api.onchange('product_id', 'picking_type_id')
def _onchange_product_id(self):
+1 -1
View File
@@ -370,7 +370,7 @@
<field name="product_packaging_id" groups="product.group_stock_packaging"/>
<field name="product_uom_qty" string="Demand" attrs="{'column_invisible': [('parent.immediate_transfer', '=', True)], 'readonly': ['|', ('is_initial_demand_editable', '=', False), '&amp;', '&amp;', ('show_operations', '=', True), ('is_locked', '=', True), ('is_initial_demand_editable', '=', False)]}"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': ['|', ('forecast_availability', '&lt;', 0), '|', ('parent.immediate_transfer', '=', True), ('parent.picking_type_code', '=', 'outgoing')]}"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': ['|', ('forecast_availability', '&gt;=', 0), '|', ('parent.immediate_transfer', '=', True), ('parent.picking_type_code', '=', 'outgoing')]}" class="text-danger"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart text-danger" attrs="{'invisible': ['|', ('forecast_availability', '&gt;=', 0), '|', ('parent.immediate_transfer', '=', True), ('parent.picking_type_code', '=', 'outgoing')]}"/>
<field name="reserved_availability" string="Reserved" attrs="{'column_invisible': (['|','|', ('parent.state','=', 'done'), ('parent.picking_type_code', 'in', ['incoming', 'outgoing']), ('parent.immediate_transfer', '=', True)])}"/>
<field name="product_qty" invisible="1" readonly="1"/>
<field name="forecast_expected_date" invisible="1" />