Files
odoo_source/addons/mrp/models/mrp_workcenter.py
T
brsy-odoo 281e1a57b3 [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>
2021-08-25 07:50:36 +00:00

379 lines
19 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil import relativedelta
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
from odoo.addons.resource.models.resource import make_aware, Intervals
from odoo.tools.float_utils import float_compare
class MrpWorkcenter(models.Model):
_name = 'mrp.workcenter'
_description = 'Work Center'
_order = "sequence, id"
_inherit = ['resource.mixin']
_check_company_auto = True
# resource
name = fields.Char('Work Center', related='resource_id.name', store=True, readonly=False)
time_efficiency = fields.Float('Time Efficiency', related='resource_id.time_efficiency', default=100, store=True, readonly=False)
active = fields.Boolean('Active', related='resource_id.active', default=True, store=True, readonly=False)
code = fields.Char('Code', copy=False)
note = fields.Html(
'Description',
help="Description of the Work Center.")
capacity = fields.Float(
'Capacity', default=1.0,
help="Number of pieces that can be produced in parallel. In case the work center has a capacity of 5 and you have to produce 10 units on your work order, the usual operation time will be multiplied by 2.")
sequence = fields.Integer(
'Sequence', default=1, required=True,
help="Gives the sequence order when displaying a list of work centers.")
color = fields.Integer('Color')
costs_hour = fields.Float(string='Cost per hour', help='Specify cost of work center per hour.', default=0.0)
time_start = fields.Float('Setup Time', help="Time in minutes for the setup.")
time_stop = fields.Float('Cleanup Time', help="Time in minutes for the cleaning.")
routing_line_ids = fields.One2many('mrp.routing.workcenter', 'workcenter_id', "Routing Lines")
order_ids = fields.One2many('mrp.workorder', 'workcenter_id', "Orders")
workorder_count = fields.Integer('# Work Orders', compute='_compute_workorder_count')
workorder_ready_count = fields.Integer('# Read Work Orders', compute='_compute_workorder_count')
workorder_progress_count = fields.Integer('Total Running Orders', compute='_compute_workorder_count')
workorder_pending_count = fields.Integer('Total Pending Orders', compute='_compute_workorder_count')
workorder_late_count = fields.Integer('Total Late Orders', compute='_compute_workorder_count')
time_ids = fields.One2many('mrp.workcenter.productivity', 'workcenter_id', 'Time Logs')
working_state = fields.Selection([
('normal', 'Normal'),
('blocked', 'Blocked'),
('done', 'In Progress')], 'Workcenter Status', compute="_compute_working_state", store=True)
blocked_time = fields.Float(
'Blocked Time', compute='_compute_blocked_time',
help='Blocked hours over the last month', digits=(16, 2))
productive_time = fields.Float(
'Productive Time', compute='_compute_productive_time',
help='Productive hours over the last month', digits=(16, 2))
oee = fields.Float(compute='_compute_oee', help='Overall Equipment Effectiveness, based on the last month')
oee_target = fields.Float(string='OEE Target', help="Overall Effective Efficiency Target in percentage", default=90)
performance = fields.Integer('Performance', compute='_compute_performance', help='Performance over the last month')
workcenter_load = fields.Float('Work Center Load', compute='_compute_workorder_count')
alternative_workcenter_ids = fields.Many2many(
'mrp.workcenter',
'mrp_workcenter_alternative_rel',
'workcenter_id',
'alternative_workcenter_id',
domain="[('id', '!=', id), '|', ('company_id', '=', company_id), ('company_id', '=', False)]",
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):
for workcenter in self:
if workcenter in workcenter.alternative_workcenter_ids:
raise ValidationError(_("Workcenter %s cannot be an alternative of itself.", workcenter.name))
@api.depends('order_ids.duration_expected', 'order_ids.workcenter_id', 'order_ids.state', 'order_ids.date_planned_start')
def _compute_workorder_count(self):
MrpWorkorder = self.env['mrp.workorder']
result = {wid: {} for wid in self._ids}
result_duration_expected = {wid: 0 for wid in self._ids}
# Count Late Workorder
data = MrpWorkorder.read_group(
[('workcenter_id', 'in', self.ids), ('state', 'in', ('pending', 'waiting', 'ready')), ('date_planned_start', '<', datetime.datetime.now().strftime('%Y-%m-%d'))],
['workcenter_id'], ['workcenter_id'])
count_data = dict((item['workcenter_id'][0], item['workcenter_id_count']) for item in data)
# Count All, Pending, Ready, Progress Workorder
res = MrpWorkorder.read_group(
[('workcenter_id', 'in', self.ids)],
['workcenter_id', 'state', 'duration_expected'], ['workcenter_id', 'state'],
lazy=False)
for res_group in res:
result[res_group['workcenter_id'][0]][res_group['state']] = res_group['__count']
if res_group['state'] in ('pending', 'waiting', 'ready', 'progress'):
result_duration_expected[res_group['workcenter_id'][0]] += res_group['duration_expected']
for workcenter in self:
workcenter.workorder_count = sum(count for state, count in result[workcenter.id].items() if state not in ('done', 'cancel'))
workcenter.workorder_pending_count = result[workcenter.id].get('pending', 0)
workcenter.workcenter_load = result_duration_expected[workcenter.id]
workcenter.workorder_ready_count = result[workcenter.id].get('ready', 0)
workcenter.workorder_progress_count = result[workcenter.id].get('progress', 0)
workcenter.workorder_late_count = count_data.get(workcenter.id, 0)
@api.depends('time_ids', 'time_ids.date_end', 'time_ids.loss_type')
def _compute_working_state(self):
for workcenter in self:
# We search for a productivity line associated to this workcenter having no `date_end`.
# If we do not find one, the workcenter is not currently being used. If we find one, according
# to its `type_loss`, the workcenter is either being used or blocked.
time_log = self.env['mrp.workcenter.productivity'].search([
('workcenter_id', '=', workcenter.id),
('date_end', '=', False)
], limit=1)
if not time_log:
# the workcenter is not being used
workcenter.working_state = 'normal'
elif time_log.loss_type in ('productive', 'performance'):
# the productivity line has a `loss_type` that means the workcenter is being used
workcenter.working_state = 'done'
else:
# the workcenter is blocked
workcenter.working_state = 'blocked'
def _compute_blocked_time(self):
# TDE FIXME: productivity loss type should be only losses, probably count other time logs differently ??
data = self.env['mrp.workcenter.productivity'].read_group([
('date_start', '>=', fields.Datetime.to_string(datetime.datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('date_end', '!=', False),
('loss_type', '!=', 'productive')],
['duration', 'workcenter_id'], ['workcenter_id'], lazy=False)
count_data = dict((item['workcenter_id'][0], item['duration']) for item in data)
for workcenter in self:
workcenter.blocked_time = count_data.get(workcenter.id, 0.0) / 60.0
def _compute_productive_time(self):
# TDE FIXME: productivity loss type should be only losses, probably count other time logs differently
data = self.env['mrp.workcenter.productivity'].read_group([
('date_start', '>=', fields.Datetime.to_string(datetime.datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('date_end', '!=', False),
('loss_type', '=', 'productive')],
['duration', 'workcenter_id'], ['workcenter_id'], lazy=False)
count_data = dict((item['workcenter_id'][0], item['duration']) for item in data)
for workcenter in self:
workcenter.productive_time = count_data.get(workcenter.id, 0.0) / 60.0
@api.depends('blocked_time', 'productive_time')
def _compute_oee(self):
for order in self:
if order.productive_time:
order.oee = round(order.productive_time * 100.0 / (order.productive_time + order.blocked_time), 2)
else:
order.oee = 0.0
def _compute_performance(self):
wo_data = self.env['mrp.workorder'].read_group([
('date_start', '>=', fields.Datetime.to_string(datetime.datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('state', '=', 'done')], ['duration_expected', 'workcenter_id', 'duration'], ['workcenter_id'], lazy=False)
duration_expected = dict((data['workcenter_id'][0], data['duration_expected']) for data in wo_data)
duration = dict((data['workcenter_id'][0], data['duration']) for data in wo_data)
for workcenter in self:
if duration.get(workcenter.id):
workcenter.performance = 100 * duration_expected.get(workcenter.id, 0.0) / duration[workcenter.id]
else:
workcenter.performance = 0.0
@api.constrains('capacity')
def _check_capacity(self):
if any(workcenter.capacity <= 0.0 for workcenter in self):
raise exceptions.UserError(_('The capacity must be strictly positive.'))
def unblock(self):
self.ensure_one()
if self.working_state != 'blocked':
raise exceptions.UserError(_("It has already been unblocked."))
times = self.env['mrp.workcenter.productivity'].search([('workcenter_id', '=', self.id), ('date_end', '=', False)])
times.write({'date_end': fields.Datetime.now()})
return {'type': 'ir.actions.client', 'tag': 'reload'}
@api.model_create_multi
def create(self, vals_list):
# resource_type is 'human' by default. As we are not living in
# /r/latestagecapitalism, workcenters are 'material'
records = super(MrpWorkcenter, self.with_context(default_resource_type='material')).create(vals_list)
return records
def write(self, vals):
if 'company_id' in vals:
self.resource_id.company_id = vals['company_id']
return super(MrpWorkcenter, self).write(vals)
def action_show_operations(self):
self.ensure_one()
action = self.env['ir.actions.actions']._for_xml_id('mrp.mrp_routing_action')
action['domain'] = [('workcenter_id', '=', self.id)]
action['context'] = {
'default_workcenter_id': self.id,
}
return action
def action_work_order(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.action_work_orders")
return action
def _get_unavailability_intervals(self, start_datetime, end_datetime):
"""Get the unavailabilities intervals for the workcenters in `self`.
Return the list of unavailabilities (a tuple of datetimes) indexed
by workcenter id.
:param start_datetime: filter unavailability with only slots after this start_datetime
:param end_datetime: filter unavailability with only slots before this end_datetime
:rtype: dict
"""
unavailability_ressources = self.resource_id._get_unavailable_intervals(start_datetime, end_datetime)
return {wc.id: unavailability_ressources.get(wc.resource_id.id, []) for wc in self}
def _get_first_available_slot(self, start_datetime, duration):
"""Get the first available interval for the workcenter in `self`.
The available interval is disjoinct with all other workorders planned on this workcenter, but
can overlap the time-off of the related calendar (inverse of the working hours).
Return the first available interval (start datetime, end datetime) or,
if there is none before 700 days, a tuple error (False, 'error message').
:param start_datetime: begin the search at this datetime
:param duration: minutes needed to make the workorder (float)
:rtype: tuple
"""
self.ensure_one()
start_datetime, revert = make_aware(start_datetime)
resource = self.resource_id
get_available_intervals = partial(self.resource_calendar_id._work_intervals_batch, domain=[('time_type', 'in', ['other', 'leave'])], resources=resource, tz=timezone(self.resource_calendar_id.tz))
get_workorder_intervals = partial(self.resource_calendar_id._leave_intervals_batch, domain=[('time_type', '=', 'other')], resources=resource, tz=timezone(self.resource_calendar_id.tz))
remaining = duration
start_interval = start_datetime
delta = timedelta(days=14)
for n in range(50): # 50 * 14 = 700 days in advance (hardcoded)
dt = start_datetime + delta * n
available_intervals = get_available_intervals(dt, dt + delta)[resource.id]
workorder_intervals = get_workorder_intervals(dt, dt + delta)[resource.id]
for start, stop, dummy in available_intervals:
interval_minutes = (stop - start).total_seconds() / 60
# If the remaining minutes has never decrease update start_interval
if remaining == duration:
start_interval = start
# If there is a overlap between the possible available interval and a others WO
if Intervals([(start_interval, start + timedelta(minutes=min(remaining, interval_minutes)), dummy)]) & workorder_intervals:
remaining = duration
start_interval = start
elif float_compare(interval_minutes, remaining, precision_digits=3) >= 0:
return revert(start_interval), revert(start + timedelta(minutes=remaining))
# Decrease a part of the remaining duration
remaining -= interval_minutes
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'
_rec_name = 'loss_type'
@api.depends('loss_type')
def name_get(self):
""" As 'category' field in form view is a Many2one, its value will be in
lower case. In order to display its value capitalized 'name_get' is
overrided.
"""
result = []
for rec in self:
result.append((rec.id, rec.loss_type.title()))
return result
loss_type = fields.Selection([
('availability', 'Availability'),
('performance', 'Performance'),
('quality', 'Quality'),
('productive', 'Productive')], string='Category', default='availability', required=True)
class MrpWorkcenterProductivityLoss(models.Model):
_name = "mrp.workcenter.productivity.loss"
_description = "Workcenter Productivity Losses"
_order = "sequence, id"
name = fields.Char('Blocking Reason', required=True)
sequence = fields.Integer('Sequence', default=1)
manual = fields.Boolean('Is a Blocking Reason', default=True)
loss_id = fields.Many2one('mrp.workcenter.productivity.loss.type', domain=([('loss_type', 'in', ['quality', 'availability'])]), string='Category')
loss_type = fields.Selection(string='Effectiveness Category', related='loss_id.loss_type', store=True, readonly=False)
class MrpWorkcenterProductivity(models.Model):
_name = "mrp.workcenter.productivity"
_description = "Workcenter Productivity Log"
_order = "id desc"
_rec_name = "loss_id"
_check_company_auto = True
def _get_default_company_id(self):
company_id = False
if self.env.context.get('default_company_id'):
company_id = self.env.context['default_company_id']
if not company_id and self.env.context.get('default_workorder_id'):
workorder = self.env['mrp.workorder'].browse(self.env.context['default_workorder_id'])
company_id = workorder.company_id
if not company_id and self.env.context.get('default_workcenter_id'):
workcenter = self.env['mrp.workcenter'].browse(self.env.context['default_workcenter_id'])
company_id = workcenter.company_id
if not company_id:
company_id = self.env.company
return company_id
production_id = fields.Many2one('mrp.production', string='Manufacturing Order', related='workorder_id.production_id', readonly=True)
workcenter_id = fields.Many2one('mrp.workcenter', "Work Center", required=True, check_company=True, index=True)
company_id = fields.Many2one(
'res.company', required=True, index=True,
default=lambda self: self._get_default_company_id())
workorder_id = fields.Many2one('mrp.workorder', 'Work Order', check_company=True, index=True)
user_id = fields.Many2one(
'res.users', "User",
default=lambda self: self.env.uid)
loss_id = fields.Many2one(
'mrp.workcenter.productivity.loss', "Loss Reason",
ondelete='restrict', required=True)
loss_type = fields.Selection(
string="Effectiveness", related='loss_id.loss_type', store=True, readonly=False)
description = fields.Text('Description')
date_start = fields.Datetime('Start Date', default=fields.Datetime.now, required=True)
date_end = fields.Datetime('End Date')
duration = fields.Float('Duration', compute='_compute_duration', store=True)
@api.depends('date_end', 'date_start')
def _compute_duration(self):
for blocktime in self:
if blocktime.date_start and blocktime.date_end:
d1 = fields.Datetime.from_string(blocktime.date_start)
d2 = fields.Datetime.from_string(blocktime.date_end)
diff = d2 - d1
if (blocktime.loss_type not in ('productive', 'performance')) and blocktime.workcenter_id.resource_calendar_id:
r = blocktime.workcenter_id._get_work_days_data_batch(d1, d2)[blocktime.workcenter_id.id]['hours']
blocktime.duration = round(r * 60, 2)
else:
blocktime.duration = round(diff.total_seconds() / 60.0, 2)
else:
blocktime.duration = 0.0
def button_block(self):
self.ensure_one()
self.workcenter_id.order_ids.end_all()