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>
379 lines
19 KiB
Python
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()
|