914 lines
45 KiB
Python
914 lines
45 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from ast import literal_eval
|
|
from bisect import bisect_left
|
|
from collections import defaultdict
|
|
from datetime import datetime
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
from pytz import utc
|
|
|
|
from odoo import Command, api, fields, models, _
|
|
from odoo.addons.web.controllers.utils import clean_action
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import float_compare, float_is_zero
|
|
from odoo.tools.intervals import Intervals
|
|
from odoo.tools.date_utils import sum_intervals
|
|
from odoo.http import request
|
|
|
|
|
|
class MrpWorkorder(models.Model):
|
|
_name = 'mrp.workorder'
|
|
_inherit = ['hr.mixin', 'mrp.workorder', 'barcodes.barcode_events_mixin']
|
|
|
|
quality_point_ids = fields.Many2many('quality.point', compute='_compute_quality_point_ids', store=True)
|
|
quality_point_count = fields.Integer('Steps', compute='_compute_quality_point_count')
|
|
|
|
check_ids = fields.One2many('quality.check', 'workorder_id')
|
|
finished_product_check_ids = fields.Many2many('quality.check', compute='_compute_finished_product_check_ids')
|
|
done_check_ids = fields.Many2many('quality.check', compute='_compute_done_check_ids')
|
|
quality_check_todo = fields.Boolean(compute='_compute_check')
|
|
quality_check_fail = fields.Boolean(compute='_compute_check')
|
|
quality_alert_ids = fields.One2many('quality.alert', 'workorder_id')
|
|
quality_alert_count = fields.Integer(compute="_compute_quality_alert_count")
|
|
|
|
current_quality_check_id = fields.Many2one(
|
|
'quality.check', "Current Quality Check", check_company=True)
|
|
|
|
# QC-related fields
|
|
allow_producing_quantity_change = fields.Boolean('Allow Changes to Producing Quantity', default=True)
|
|
|
|
is_last_lot = fields.Boolean('Is Last lot', compute='_compute_is_last_lot')
|
|
is_first_started_wo = fields.Boolean('Is The first Work Order', compute='_compute_is_last_unfinished_wo')
|
|
is_last_unfinished_wo = fields.Boolean('Is Last Work Order To Process', compute='_compute_is_last_unfinished_wo', store=False)
|
|
move_id = fields.Many2one(related='current_quality_check_id.move_id', readonly=False)
|
|
move_line_ids = fields.One2many(related='move_id.move_line_ids')
|
|
quality_state = fields.Selection(related='current_quality_check_id.quality_state', string="Quality State", readonly=False)
|
|
test_type_id = fields.Many2one('quality.point.test_type', 'Test Type', related='current_quality_check_id.test_type_id')
|
|
test_type = fields.Char(related='test_type_id.technical_name')
|
|
user_id = fields.Many2one(related='current_quality_check_id.user_id', readonly=False)
|
|
worksheet_page = fields.Integer('Worksheet page')
|
|
picture = fields.Binary(related='current_quality_check_id.picture', readonly=False)
|
|
product_description_variants = fields.Char(related='production_id.product_description_variants')
|
|
|
|
# used to display the connected employee that will start a workorder on the tablet view
|
|
employee_id = fields.Many2one('hr.employee', string="Employee", compute='_compute_employee_id')
|
|
employee_name = fields.Char(compute='_compute_employee_id')
|
|
|
|
# employees that started working on the wo
|
|
employee_ids = fields.Many2many('hr.employee', string='Working employees', copy=False)
|
|
# employees assigned to the wo
|
|
employee_assigned_ids = fields.Many2many('hr.employee', 'mrp_workorder_employee_assigned',
|
|
'workorder_id', 'employee_id', string='Assigned',
|
|
domain="[('company_id', 'in', allowed_company_ids)]")
|
|
# employees connected
|
|
connected_employee_ids = fields.Many2many('hr.employee', search='search_is_assigned_to_connected', store=False)
|
|
|
|
# list of employees allowed to work on the workcenter
|
|
allowed_employees = fields.Many2many(related='workcenter_id.employee_ids')
|
|
# True if all employees are allowed on that workcenter
|
|
all_employees_allowed = fields.Boolean(compute='_all_employees_allowed')
|
|
|
|
# Technical field to store the estimated hourly cost of employee at time of work order completion (i.e. to keep a consistent cost).
|
|
employee_costs_hour = fields.Float(string='Employee Cost per hour', default=0.0)
|
|
|
|
@api.depends('operation_id')
|
|
def _compute_quality_point_ids(self):
|
|
for workorder in self:
|
|
quality_points = workorder.operation_id.quality_point_ids
|
|
quality_points = quality_points.filtered(lambda qp: (not qp.product_ids or workorder.production_id.product_id in qp.product_ids) and (qp.company_id == workorder.company_id))
|
|
workorder.quality_point_ids = quality_points
|
|
|
|
@api.depends('operation_id')
|
|
def _compute_quality_point_count(self):
|
|
for workorder in self:
|
|
quality_point = workorder.operation_id.quality_point_ids
|
|
workorder.quality_point_count = len(quality_point)
|
|
|
|
@api.depends('qty_producing', 'qty_remaining')
|
|
def _compute_is_last_lot(self):
|
|
for wo in self:
|
|
precision = wo.production_id.product_uom_id.rounding
|
|
wo.is_last_lot = float_compare(wo.qty_producing, wo.qty_remaining, precision_rounding=precision) >= 0
|
|
|
|
@api.depends('production_id.workorder_ids')
|
|
def _compute_is_last_unfinished_wo(self):
|
|
for wo in self:
|
|
wo.is_first_started_wo = all(wo.state != 'done' for wo in (wo.production_id.workorder_ids - wo))
|
|
other_wos = wo.production_id.workorder_ids - wo
|
|
other_states = other_wos.mapped(lambda w: w.state in ['done', 'cancel'])
|
|
wo.is_last_unfinished_wo = all(other_states)
|
|
|
|
@api.depends('check_ids')
|
|
def _compute_finished_product_check_ids(self):
|
|
for wo in self:
|
|
wo.finished_product_check_ids = wo.check_ids.filtered(lambda c: c.finished_product_sequence == wo.qty_produced)
|
|
|
|
@api.depends('check_ids.quality_state')
|
|
def _compute_done_check_ids(self):
|
|
for wo in self:
|
|
wo.done_check_ids = wo.check_ids.filtered(lambda c: c.quality_state != 'none')
|
|
|
|
def unlink(self):
|
|
self.check_ids.sudo().unlink()
|
|
return super().unlink()
|
|
|
|
def action_back(self):
|
|
self.ensure_one()
|
|
if self._should_be_pending():
|
|
self.button_pending()
|
|
domain = [('state', 'not in', ['done', 'cancel', 'blocked'])]
|
|
if self.env.context.get('from_manufacturing_order'):
|
|
# from workorder on MO
|
|
action = self.env["ir.actions.actions"]._for_xml_id("mrp_workorder.mrp_workorder_action_tablet")
|
|
action['domain'] = domain
|
|
action['context'] = literal_eval(action['context']) | {
|
|
'no_breadcrumbs': True,
|
|
'search_default_production_id': self.production_id.id,
|
|
'search_default_workcenter_id': False,
|
|
'search_default_progress': False,
|
|
'search_default_ready': False,
|
|
'from_manufacturing_order': True,
|
|
}
|
|
elif self.env.context.get('from_production_order'):
|
|
# from workorder list view
|
|
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_workorder_todo")
|
|
action['target'] = 'main'
|
|
action['context'] = dict(literal_eval(action['context']), no_breadcrumbs=True)
|
|
elif self.env.context.get('mrp_display'):
|
|
return
|
|
else:
|
|
# from workcenter kanban view
|
|
action = self.env["ir.actions.actions"]._for_xml_id("mrp_workorder.mrp_workorder_action_tablet")
|
|
action['domain'] = domain
|
|
action['context'] = literal_eval(action['context'].replace('active_id', str(self.id))) | {
|
|
'no_breadcrumbs': True,
|
|
'search_default_workcenter_id': self.workcenter_id.id,
|
|
}
|
|
if self.employee_id:
|
|
action['context']['employee_id'] = self.employee_id.id
|
|
action['context']['employee_name'] = self.employee_id.name
|
|
if self.employee_ids:
|
|
action['context']['employee_ids'] = self.employee_ids
|
|
return clean_action(action, self.env)
|
|
|
|
def action_cancel(self):
|
|
self.mapped('check_ids').filtered(lambda c: c.quality_state == 'none').sudo().unlink()
|
|
return super().action_cancel()
|
|
|
|
def action_generate_serial(self):
|
|
self.ensure_one()
|
|
return self.production_id.action_generate_serial(self)
|
|
|
|
def _change_quality_check(self, position):
|
|
"""Change the quality check currently set on the workorder `self`.
|
|
|
|
The workorder points to a check. A check belongs to a chain.
|
|
This method allows to change the selected check by moving on the checks
|
|
chain according to `position`.
|
|
|
|
:param position: Where we need to change the cursor on the check chain
|
|
:type position: string
|
|
"""
|
|
self.ensure_one()
|
|
assert position in ['first', 'next', 'previous', 'last']
|
|
checks_to_consider = self.check_ids.filtered(lambda c: c.quality_state == 'none')
|
|
if position == 'first':
|
|
check = checks_to_consider.filtered(lambda check: not check.previous_check_id)
|
|
elif position == 'next':
|
|
check = self.current_quality_check_id.next_check_id
|
|
if not check:
|
|
check = checks_to_consider[:1]
|
|
elif check.quality_state != 'none':
|
|
self.current_quality_check_id = check
|
|
return self._change_quality_check(position='next')
|
|
elif position == 'previous':
|
|
check = self.current_quality_check_id.previous_check_id
|
|
else:
|
|
check = checks_to_consider.filtered(lambda check: not check.next_check_id)
|
|
self.write({
|
|
'allow_producing_quantity_change':
|
|
not check.previous_check_id.filtered(lambda c: c.quality_state != 'fail')
|
|
and all(c.quality_state != 'fail' for c in checks_to_consider)
|
|
and self.is_first_started_wo,
|
|
'current_quality_check_id': check.id,
|
|
})
|
|
return check.id
|
|
|
|
def action_menu(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'mrp.workorder',
|
|
'views': [[self.env.ref('mrp_workorder.mrp_workorder_view_form_tablet_menu').id, 'form']],
|
|
'name': _('Menu'),
|
|
'target': 'new',
|
|
'res_id': self.id,
|
|
}
|
|
|
|
def action_add_component(self):
|
|
self.ensure_one()
|
|
return self.production_id.with_context(workorder_id=self.id).action_add_component()
|
|
|
|
def action_add_byproduct(self):
|
|
self.ensure_one()
|
|
return self.production_id.with_context(workorder_id=self.id).action_add_byproduct()
|
|
|
|
def action_add_workorder(self):
|
|
self.ensure_one()
|
|
default_blocking_wo_id = self.id
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'mrp_production.additional.workorder',
|
|
'views': [[self.env.ref('mrp_workorder.view_mrp_production_additional_workorder_wizard').id, 'form']],
|
|
'name': _('Add Work Order'),
|
|
'target': 'new',
|
|
'context': {
|
|
'default_production_id': self.production_id.id,
|
|
'default_blocked_by_workorder_id': default_blocking_wo_id,
|
|
}
|
|
}
|
|
|
|
def button_start(self, raise_on_invalid_state=False, bypass=False):
|
|
skip_employee_check = bypass or (not request and not self.env.user.employee_id)
|
|
main_employee = False
|
|
if not skip_employee_check:
|
|
if not self.env.context.get('mrp_display'):
|
|
main_employee = self.env.user.employee_id.id
|
|
if not self.env.user.employee_id:
|
|
raise UserError(_("You need to link this user to an employee of this company to process the work order"))
|
|
else:
|
|
connected_employees = self.env['hr.employee'].get_employees_connected()
|
|
if len(connected_employees) == 0:
|
|
raise UserError(_("You need to log in to process this work order."))
|
|
main_employee = self.env['hr.employee'].get_session_owner()
|
|
if not main_employee:
|
|
raise UserError(_("There is no session chief. Please log in."))
|
|
if any(main_employee not in [emp.id for emp in wo.allowed_employees] and not wo.all_employees_allowed for wo in self):
|
|
raise UserError(_("You are not allowed to work on the workorder"))
|
|
|
|
res = super().button_start(raise_on_invalid_state=raise_on_invalid_state)
|
|
|
|
if main_employee:
|
|
main_employee = self.env['hr.employee'].browse(main_employee)
|
|
for wo in self:
|
|
if (len(wo.allowed_employees) == 0 or main_employee in wo.allowed_employees) and wo.state not in ('done', 'cancel'):
|
|
wo.start_employee(main_employee.id)
|
|
wo.employee_ids |= main_employee
|
|
wo.employee_assigned_ids |= main_employee
|
|
|
|
return res
|
|
|
|
def button_finish(self):
|
|
""" When using the Done button of the simplified view, validate directly some types of quality checks
|
|
"""
|
|
for workorder in self:
|
|
if workorder.state in ('done', 'cancel'):
|
|
continue
|
|
workorder.employee_costs_hour = workorder.workcenter_id.employee_costs_hour
|
|
self.verify_quality_checks()
|
|
return super().button_finish()
|
|
|
|
def verify_quality_checks(self):
|
|
for check in self.check_ids:
|
|
if check.quality_state in ['pass', 'fail']:
|
|
continue
|
|
if check.test_type in ['register_consumed_materials', 'register_byproducts', 'instructions']:
|
|
check.quality_state = 'pass'
|
|
else:
|
|
raise UserError(_("You need to complete Quality Checks using the Shop Floor before marking Work Order as Done."))
|
|
|
|
def action_propose_change(self, change_type, check_id):
|
|
change_type_to_title = {'update_step': 'Update Instructions', 'remove_step': 'Remove Step', 'set_picture': 'Change Picture'}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'propose.change',
|
|
'views': [[self.env.ref('mrp_workorder.view_propose_change_wizard').id, 'form']],
|
|
'name': change_type_to_title[change_type],
|
|
'target': 'new',
|
|
'context': {
|
|
'default_workorder_id': self.id,
|
|
'default_step_id': check_id,
|
|
'default_change_type': change_type,
|
|
}
|
|
}
|
|
|
|
def action_add_step(self):
|
|
self.ensure_one()
|
|
if self.current_quality_check_id:
|
|
team = self.current_quality_check_id.team_id
|
|
else:
|
|
team = self.env['quality.alert.team'].search(['|', ('company_id', '=', self.company_id.id), ('company_id', '=', False)], limit=1)
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'quality.check',
|
|
'views': [[self.env.ref('mrp_workorder.add_quality_check_from_tablet').id, 'form']],
|
|
'name': _('Add a Step'),
|
|
'target': 'new',
|
|
'context': {
|
|
'default_test_type_id': self.env.ref('quality.test_type_instructions').id,
|
|
'default_workorder_id': self.id,
|
|
'default_production_id': self.production_id.id,
|
|
'default_product_id': self.product_id.id,
|
|
'default_team_id': team.id,
|
|
}
|
|
}
|
|
|
|
def action_open_mes(self):
|
|
action = self.env['ir.actions.actions']._for_xml_id('mrp_workorder.action_mrp_display')
|
|
action['context'] = literal_eval(action['context']) | {
|
|
'workcenter_id': self.workcenter_id.id,
|
|
'search_default_progress': False,
|
|
'search_default_ready': False,
|
|
'search_default_name': self.production_id.name,
|
|
'shouldHideNewWorkcenterButton': True,
|
|
}
|
|
del action['context']['search_default_filter_ready'] # Remove MO Ready
|
|
return action
|
|
|
|
def action_log_note(self):
|
|
return self.production_id.action_log_note()
|
|
|
|
def _compute_check(self):
|
|
for workorder in self:
|
|
todo = False
|
|
fail = False
|
|
for check in workorder.check_ids:
|
|
if check.quality_state == 'none':
|
|
todo = True
|
|
elif check.quality_state == 'fail':
|
|
fail = True
|
|
if fail and todo:
|
|
break
|
|
workorder.quality_check_fail = fail
|
|
workorder.quality_check_todo = todo
|
|
|
|
def _compute_quality_alert_count(self):
|
|
for workorder in self:
|
|
workorder.quality_alert_count = len(workorder.quality_alert_ids)
|
|
|
|
def _create_checks(self):
|
|
for wo in self:
|
|
# Track components which have a control point
|
|
processed_move = self.env['stock.move']
|
|
|
|
production = wo.production_id
|
|
|
|
move_raw_ids = wo.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
|
|
move_finished_ids = wo.move_finished_ids.filtered(lambda m: m.state not in ('done', 'cancel') and m.product_id != wo.production_id.product_id)
|
|
previous_check = self.env['quality.check']
|
|
for point in wo.quality_point_ids:
|
|
# Check if we need a quality control for this point
|
|
if point.check_execute_now():
|
|
moves = self.env['stock.move']
|
|
values = {
|
|
'production_id': production.id,
|
|
'workorder_id': wo.id,
|
|
'point_id': point.id,
|
|
'team_id': point.team_id.id,
|
|
'company_id': wo.company_id.id,
|
|
'product_id': production.product_id.id,
|
|
# Two steps are from the same production
|
|
# if and only if the produced quantities at the time they were created are equal.
|
|
'finished_product_sequence': wo.qty_produced,
|
|
'previous_check_id': previous_check.id,
|
|
'worksheet_document': point.worksheet_document,
|
|
}
|
|
if point.test_type == 'register_byproducts':
|
|
moves = move_finished_ids.filtered(lambda m: m.product_id == point.component_id)
|
|
if not moves:
|
|
moves = production.move_finished_ids.filtered(lambda m: not m.operation_id and m.product_id == point.component_id)
|
|
elif point.test_type == 'register_consumed_materials':
|
|
moves = move_raw_ids.filtered(lambda m: m.product_id == point.component_id)
|
|
if not moves:
|
|
moves = production.move_raw_ids.filtered(lambda m: not m.operation_id and m.product_id == point.component_id)
|
|
else:
|
|
check = self.env['quality.check'].create(values)
|
|
previous_check.next_check_id = check
|
|
previous_check = check
|
|
# Create 'register ...' checks
|
|
for move in moves:
|
|
check_vals = values.copy()
|
|
check_vals.update(wo._defaults_from_move(move))
|
|
# Create quality check and link it to the chain
|
|
check_vals.update({'previous_check_id': previous_check.id})
|
|
check = self.env['quality.check'].create(check_vals)
|
|
previous_check.next_check_id = check
|
|
previous_check = check
|
|
processed_move |= moves
|
|
|
|
# Set default quality_check
|
|
wo._change_quality_check(position='first')
|
|
|
|
def _get_byproduct_move_to_update(self):
|
|
moves = super()._get_byproduct_move_to_update()
|
|
return moves.filtered(lambda m: m.product_id.tracking == 'none')
|
|
|
|
def pre_record_production(self):
|
|
self.ensure_one()
|
|
self._check_company()
|
|
if any(x.quality_state == 'none' for x in self.check_ids if x.test_type != 'instructions'):
|
|
raise UserError(_('You still need to do the quality checks!'))
|
|
if float_compare(self.qty_producing, 0, precision_rounding=self.product_uom_id.rounding) <= 0:
|
|
raise UserError(_('Please set the quantity you are currently producing. It should be different from zero.'))
|
|
|
|
def record_production(self):
|
|
if not self:
|
|
return True
|
|
|
|
self.pre_record_production()
|
|
|
|
backorder = False
|
|
# Trigger the backorder process if we produce less than expected
|
|
if float_compare(self.qty_producing, self.qty_remaining, precision_rounding=self.product_uom_id.rounding) == -1 and self.is_first_started_wo:
|
|
match self.production_id.picking_type_id.create_backorder:
|
|
case "ask":
|
|
return self.production_id.with_context(workorder_id_to_finish=self.id)._action_generate_backorder_wizard(self.production_id)
|
|
case "always":
|
|
backorder = self.production_id._split_productions({self.production_id: [self.qty_producing, self.qty_remaining - self.qty_producing]})[1:]
|
|
for workorder in backorder.workorder_ids:
|
|
if not self.env.context.get('no_start_next', False):
|
|
workorder.qty_producing = workorder.qty_remaining
|
|
self.production_id.product_qty = self.qty_producing
|
|
|
|
else:
|
|
if self.operation_id:
|
|
backorder = (self.production_id.production_group_id.production_ids - self.production_id).filtered(
|
|
lambda p: p.workorder_ids.filtered(lambda wo: wo.operation_id == self.operation_id).state not in ('cancel', 'done')
|
|
)[:1]
|
|
else:
|
|
index = list(self.production_id.workorder_ids).index(self)
|
|
backorder = (self.production_id.production_group_id.production_ids - self.production_id).filtered(
|
|
lambda p: index < len(p.workorder_ids) and p.workorder_ids[index].state not in ('cancel', 'done')
|
|
)[:1]
|
|
|
|
return self.post_record_production(backorder)
|
|
|
|
def post_record_production(self, backorders=False):
|
|
|
|
self.button_finish()
|
|
|
|
if backorders:
|
|
for wo in (self.production_id | backorders).workorder_ids:
|
|
if wo.state in ('done', 'cancel'):
|
|
continue
|
|
wo.current_quality_check_id.update(wo._defaults_from_move(wo.move_id))
|
|
|
|
return self.action_back()
|
|
|
|
def _defaults_from_move(self, move):
|
|
self.ensure_one()
|
|
vals = {'move_id': move.id}
|
|
move_line_id = move.move_line_ids[:1]
|
|
if move_line_id:
|
|
vals.update({
|
|
'lot_ids': move_line_id.lot_id.ids,
|
|
})
|
|
return vals
|
|
# --------------------------
|
|
# Buttons from quality.check
|
|
# --------------------------
|
|
|
|
def open_tablet_view(self):
|
|
self.ensure_one()
|
|
self.env['hr.employee'].login_user_employee()
|
|
if self._should_start():
|
|
self.button_start()
|
|
action = self.env["ir.actions.actions"]._for_xml_id("mrp_workorder.tablet_client_action")
|
|
action['target'] = 'fullscreen'
|
|
action['res_id'] = self.id
|
|
action['context'] = {
|
|
'active_id': self.id,
|
|
'from_production_order': self.env.context.get('from_production_order'),
|
|
'from_manufacturing_order': self.env.context.get('from_manufacturing_order')
|
|
}
|
|
return action
|
|
|
|
def action_open_manufacturing_order(self):
|
|
no_start_next = self.env.context.get("no_start_next", True)
|
|
action = self.with_context(no_start_next=no_start_next).do_finish()
|
|
try:
|
|
with self.env.cr.savepoint():
|
|
res = self.production_id.button_mark_done()
|
|
if res is not True:
|
|
res['context'] = dict(res.get('context', {}), from_workorder=True)
|
|
return res
|
|
except (UserError, ValidationError) as e:
|
|
# log next activity on MO with error message
|
|
self.production_id.activity_schedule(
|
|
'mail.mail_activity_data_warning',
|
|
note=str(e),
|
|
summary=('The %s could not be closed') % (self.production_id.name),
|
|
user_id=self.env.user.id)
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'mrp.production',
|
|
'views': [[self.env.ref('mrp.mrp_production_form_view').id, 'form']],
|
|
'res_id': self.production_id.id,
|
|
'target': 'main',
|
|
}
|
|
return action
|
|
|
|
def do_finish(self):
|
|
self.end_all()
|
|
if self.state != 'done':
|
|
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'productive')], limit=1)
|
|
if len(loss_id) < 1:
|
|
raise UserError(_("You need to define at least one productivity loss in the category 'Productive'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
|
|
action = self.record_production()
|
|
self._set_default_time_log(loss_id)
|
|
if action is not True:
|
|
return action
|
|
# workorder list view action should redirect to the same view instead of workorder kanban view when WO mark as done.
|
|
return self.action_back()
|
|
|
|
def get_summary_data(self):
|
|
self.ensure_one()
|
|
# show rainbow man only the first time
|
|
show_rainbow = any(not t.date_end for t in self.time_ids)
|
|
self.end_all()
|
|
if any(step.quality_state == 'none' for step in self.check_ids):
|
|
raise UserError(_('You still need to do the quality checks!'))
|
|
last30op = self.env['mrp.workorder'].search_read([
|
|
('operation_id', '=', self.operation_id.id),
|
|
('state', '=', 'done'),
|
|
('date_finished', '>', fields.Date.today() - relativedelta(days=30)),
|
|
], ['duration', 'qty_produced'])
|
|
last30op = sorted([item['duration'] / item['qty_produced'] for item in last30op])
|
|
# show rainbow man only for the best time in the last 30 days.
|
|
if last30op:
|
|
show_rainbow = show_rainbow and float_compare((self.duration / self.qty_producing), last30op[0], precision_digits=2) <= 0
|
|
|
|
score = 3
|
|
if self.check_ids:
|
|
passed_checks = len([check for check in self.check_ids if check.quality_state == 'pass'])
|
|
score = int(3.0 * passed_checks / len(self.check_ids))
|
|
|
|
return {
|
|
'duration': self.duration,
|
|
'position': bisect_left(last30op, self.duration), # which position regarded other workorders ranked by duration
|
|
'quality_score': score,
|
|
'show_rainbow': show_rainbow,
|
|
}
|
|
|
|
def _action_confirm(self):
|
|
res = super()._action_confirm()
|
|
self.filtered(lambda wo: wo.state != 'cancel' and not wo.check_ids)._create_checks()
|
|
return res
|
|
|
|
def _update_qty_producing(self, quantity):
|
|
if float_is_zero(quantity, precision_rounding=self.product_uom_id.rounding):
|
|
self.check_ids.unlink()
|
|
super()._update_qty_producing(quantity)
|
|
|
|
def _web_gantt_reschedule_is_record_candidate(self, start_date_field_name, stop_date_field_name):
|
|
return self.state not in ['progress', 'done', 'cancel'] \
|
|
and super()._web_gantt_reschedule_is_record_candidate(start_date_field_name, stop_date_field_name)
|
|
|
|
def _web_gantt_reschedule_compute_dates(self, date_candidate, search_forward, start_date_field_name, stop_date_field_name):
|
|
from_date, to_date = self.workcenter_id._get_first_available_slot(date_candidate, self.duration_expected, forward=search_forward, leaves_to_ignore=self.leave_id)
|
|
return [from_date, to_date]
|
|
|
|
def _web_gantt_reschedule_write_new_dates(self, new_start_date, new_stop_date, start_date_field_name, stop_date_field_name):
|
|
return super(MrpWorkorder, self.with_context(bypass_duration_calculation=True))._web_gantt_reschedule_write_new_dates(new_start_date, new_stop_date, start_date_field_name, stop_date_field_name)
|
|
|
|
def _web_gantt_progress_bar_workcenter_id(self, res_ids, start, stop):
|
|
self.env['mrp.workorder'].check_access('read')
|
|
workcenters = self.env['mrp.workcenter'].search([('id', 'in', res_ids)])
|
|
workorders = self.env['mrp.workorder'].search([
|
|
('workcenter_id', 'in', res_ids),
|
|
('state', 'not in', ['done', 'cancel']),
|
|
('date_start', '<=', stop.replace(tzinfo=None)),
|
|
('date_finished', '>=', start.replace(tzinfo=None)),
|
|
])
|
|
planned_hours = defaultdict(float)
|
|
workcenters_work_intervals, dummy = workcenters.resource_id._get_valid_work_intervals(start, stop)
|
|
for workorder in workorders:
|
|
max_start = max(start, utc.localize(workorder.date_start))
|
|
min_finished = min(stop, utc.localize(workorder.date_finished))
|
|
interval = Intervals([(max_start, min_finished, self.env['resource.calendar.attendance'])])
|
|
work_intervals = interval & workcenters_work_intervals[workorder.workcenter_id.resource_id.id]
|
|
planned_hours[workorder.workcenter_id] += sum_intervals(work_intervals)
|
|
work_hours = {
|
|
id: sum_intervals(work_intervals) for id, work_intervals in workcenters_work_intervals.items()
|
|
}
|
|
return {
|
|
workcenter.id: {
|
|
'value': planned_hours[workcenter],
|
|
'max_value': work_hours.get(workcenter.resource_id.id, 0.0),
|
|
}
|
|
for workcenter in workcenters
|
|
}
|
|
|
|
@api.model
|
|
def get_gantt_data(self, domain, groupby, read_specification, limit=None, offset=0, unavailability_fields=None, progress_bar_fields=None, start_date=None, stop_date=None, scale=None):
|
|
gantt_data = super(MrpWorkorder, self.with_context(prefix_product=True)).get_gantt_data(domain, groupby, read_specification, limit=limit, offset=offset, unavailability_fields=unavailability_fields, progress_bar_fields=progress_bar_fields, start_date=start_date, stop_date=stop_date, scale=scale)
|
|
if 'workcenter_id' not in gantt_data['unavailabilities']:
|
|
workcenter_ids = set()
|
|
if groupby and 'workcenter_id' in groupby:
|
|
for group in gantt_data['groups']:
|
|
res_id = group['workcenter_id'][0] if group['workcenter_id'] else False
|
|
workcenter_ids.add(res_id)
|
|
else:
|
|
for record in gantt_data['records']:
|
|
res_id = record['workcenter_id']['id'] if record.get('workcenter_id') else False
|
|
workcenter_ids.add(res_id)
|
|
start, stop = fields.Datetime.from_string(start_date), fields.Datetime.from_string(stop_date)
|
|
gantt_data['unavailabilities']['workcenter_id'] = self._gantt_unavailability('workcenter_id', workcenter_ids, start, stop, scale)
|
|
return gantt_data
|
|
|
|
@api.model
|
|
def _gantt_progress_bar(self, field, res_ids, start, stop):
|
|
start, stop = utc.localize(start), utc.localize(stop)
|
|
today = datetime.now(utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
start = max(start, today)
|
|
if field == 'workcenter_id':
|
|
return dict(
|
|
self._web_gantt_progress_bar_workcenter_id(res_ids, start, stop),
|
|
warning=_("This workcenter isn't expected to have open workorders during this period. Work hours :"),
|
|
)
|
|
raise NotImplementedError("This Progress Bar is not implemented.")
|
|
|
|
@api.model
|
|
def _gantt_unavailability(self, field, res_ids, start, stop, scale):
|
|
"""Get unavailabilities data to display in the Gantt view."""
|
|
if field != 'workcenter_id':
|
|
return super()._gantt_unavailability(field, res_ids, start, stop, scale)
|
|
|
|
workcenters = self.env['mrp.workcenter'].browse(res_ids)
|
|
unavailability_mapping = workcenters._get_unavailability_intervals(start, stop)
|
|
|
|
result = {}
|
|
for workcenter in workcenters:
|
|
result[workcenter.id] = [{'start': interval[0], 'stop': interval[1]} for interval in unavailability_mapping[workcenter.id]]
|
|
return result
|
|
|
|
@api.model
|
|
def get_shopfloor_limit(self):
|
|
return self.env['ir.config_parameter'].sudo().get_param("mrp_workorder.wo_shop_floor_maximum_card_count")
|
|
|
|
def _should_be_pending(self):
|
|
return self.is_user_working and self.working_state != 'blocked' and len(self.employee_ids.ids) == 0
|
|
|
|
def _should_start(self):
|
|
if self.working_state != 'blocked' and self.state in ('ready', 'blocked', 'progress'):
|
|
if self.env['hr.employee'].get_session_owner():
|
|
return True
|
|
else:
|
|
self.button_start(bypass=True)
|
|
return False
|
|
|
|
def _compute_duration(self):
|
|
wo_ids_without_employees = set()
|
|
for wo in self:
|
|
wo.duration = wo.get_duration()
|
|
wo.duration_unit = round(wo.duration / max(wo.qty_produced, 1), 2)
|
|
if wo.duration_expected:
|
|
wo.duration_percent = max(-2147483648, min(2147483647, 100 * (wo.duration_expected - wo.duration) / wo.duration_expected))
|
|
else:
|
|
wo.duration_percent = 0
|
|
return super(MrpWorkorder, self.env['mrp.workorder'].browse(wo_ids_without_employees))._compute_duration()
|
|
|
|
@api.depends('employee_ids')
|
|
def _compute_employee_id(self):
|
|
main_employee_connected = self.env['hr.employee'].get_session_owner()
|
|
self.employee_id = main_employee_connected
|
|
self.employee_name = self.env['hr.employee'].browse(main_employee_connected).name
|
|
|
|
def search_is_assigned_to_connected(self, operator, value):
|
|
# retrieving employees connected in the session
|
|
main_employee_connected = self.env['hr.employee'].get_session_owner()
|
|
# if no one is connected, all records are valid
|
|
if not main_employee_connected:
|
|
return []
|
|
search_query = self.env['mrp.workorder']._search([('employee_assigned_ids', '=', main_employee_connected)])
|
|
return [('id', operator, search_query)]
|
|
|
|
@api.depends('all_employees_allowed')
|
|
def _all_employees_allowed(self):
|
|
for wo in self:
|
|
wo.all_employees_allowed = len(wo.allowed_employees) == 0
|
|
|
|
def start_employee(self, employee_id):
|
|
self.ensure_one()
|
|
if employee_id in self.employee_ids.ids and any(not t.date_end for t in self.time_ids if t.employee_id.id == employee_id):
|
|
return
|
|
self.employee_ids = [Command.link(employee_id)]
|
|
time_data = self._prepare_timeline_vals(self.duration, fields.Datetime.now())
|
|
time_data['employee_id'] = employee_id
|
|
self.env['mrp.workcenter.productivity'].create(time_data)
|
|
self.state = "progress"
|
|
|
|
def stop_employee(self, employee_ids):
|
|
self.employee_ids = [Command.unlink(emp) for emp in employee_ids]
|
|
self.env['mrp.workcenter.productivity'].search([
|
|
('employee_id', 'in', employee_ids),
|
|
('workorder_id', 'in', self.ids),
|
|
('date_end', '=', False)
|
|
])._close()
|
|
|
|
def _should_start_timer(self):
|
|
""" Return True if the timer should start once the workorder is opened."""
|
|
self.ensure_one()
|
|
return False
|
|
|
|
def _intervals_duration(self, intervals):
|
|
""" Return the duration of the given intervals.
|
|
If intervals overlaps the duration is only counted once.
|
|
|
|
The timer could be share between several intervals. However it is not
|
|
an issue since the purpose is to make a difference between employee time and
|
|
blocking time.
|
|
|
|
:param list intervals: list of tuple (date_start, date_end, timer)
|
|
"""
|
|
if not intervals:
|
|
return 0.0
|
|
duration = 0
|
|
for date_start, date_stop, timer in Intervals(intervals):
|
|
duration += timer.loss_id._convert_to_duration(date_start, date_stop, timer.workcenter_id)
|
|
return duration
|
|
|
|
def get_duration(self):
|
|
self.ensure_one()
|
|
now = self.env.cr.now()
|
|
loss_type_times = defaultdict(lambda: self.env['mrp.workcenter.productivity'])
|
|
for time in self.time_ids:
|
|
loss_type_times[time.loss_id.loss_type] |= time
|
|
duration = 0
|
|
for times in loss_type_times.values():
|
|
duration += self._intervals_duration([(t.date_start or now, t.date_end or now, t) for t in times])
|
|
return duration
|
|
|
|
def get_working_duration(self):
|
|
self.ensure_one()
|
|
now = self.env.cr.now()
|
|
return self._intervals_duration([(t.date_start, now, t) for t in self.time_ids if not t.date_end])
|
|
|
|
def get_productive_duration(self):
|
|
self.ensure_one()
|
|
now = fields.Datetime.now()
|
|
productive_times = []
|
|
for time in self.time_ids:
|
|
if time.loss_id.loss_type == "productive":
|
|
productive_times.append(time)
|
|
duration = self._intervals_duration([(t.date_start, t.date_end or now, t) for t in productive_times])
|
|
return duration
|
|
|
|
def _cal_cost(self, date=False):
|
|
total_workcenter_cost = super()._cal_cost(date)
|
|
for wo in self:
|
|
if wo._should_estimate_cost():
|
|
total_workcenter_cost += (wo.duration_expected / 60) * (wo.employee_costs_hour or wo.workcenter_id.employee_costs_hour)
|
|
elif date:
|
|
total_workcenter_cost += sum(wo.time_ids.filtered(lambda t: t.date_end and t.date_end <= date).mapped('total_cost'))
|
|
else:
|
|
total_workcenter_cost += sum(wo.time_ids.mapped('total_cost'))
|
|
return total_workcenter_cost
|
|
|
|
def button_pending(self):
|
|
Employee = self.env['hr.employee']
|
|
# Determine current employee
|
|
if not self.env.context.get('mrp_display'):
|
|
employee = self.env.user.employee_id
|
|
else:
|
|
connected_employees = Employee.get_employees_connected()
|
|
if not connected_employees:
|
|
raise UserError(_("You need to log in to process this work order."))
|
|
employee = Employee.get_session_owner()
|
|
if employee:
|
|
# Stop productivity only for the current employee
|
|
self.stop_employee([employee.id])
|
|
super().button_pending()
|
|
|
|
def action_mark_as_done(self):
|
|
if self.env.context.get('mrp_display'):
|
|
main_employee_connected = self.env['hr.employee'].get_session_owner()
|
|
else:
|
|
main_employee_connected = self.env.user.employee_id.id
|
|
|
|
for wo in self:
|
|
if not main_employee_connected:
|
|
raise UserError(_('You must be logged in to process some of these work orders.'))
|
|
if len(wo.allowed_employees) != 0 and main_employee_connected not in [wo.id for wo in wo.allowed_employees]:
|
|
raise UserError(_('You are not allow to work on some of these work orders.'))
|
|
if wo.working_state == 'blocked':
|
|
raise UserError(_('Please unblock the work center to start the work order'))
|
|
self.button_finish()
|
|
|
|
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'productive')], limit=1)
|
|
if len(loss_id) < 1:
|
|
raise UserError(_("You need to define at least one productivity loss in the category 'Productive'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
|
|
|
|
self.state = 'done'
|
|
self._set_default_time_log(loss_id)
|
|
|
|
def _set_default_time_log(self, loss_id):
|
|
productivity = []
|
|
for wo in self:
|
|
if not wo.time_ids:
|
|
now = fields.Datetime.now()
|
|
date_start = datetime.fromtimestamp(now.timestamp() - ((wo.duration_expected * 60) // 1))
|
|
date_end = now
|
|
connected_employee = wo._get_connected_employee()
|
|
productivity.append({
|
|
'workorder_id': wo.id,
|
|
'workcenter_id': wo.workcenter_id.id,
|
|
'description': _('Time Tracking: %(user)s', user=connected_employee.name),
|
|
'date_start': date_start,
|
|
'date_end': date_end,
|
|
'loss_id': loss_id[0].id,
|
|
'user_id': self.env.user.id,
|
|
'company_id': wo.company_id.id,
|
|
'employee_id': connected_employee.id,
|
|
})
|
|
self.env['mrp.workcenter.productivity'].create(productivity)
|
|
|
|
def _compute_expected_operation_cost(self, without_employee_cost=False):
|
|
expected_machine_cost = super()._compute_expected_operation_cost()
|
|
if without_employee_cost:
|
|
return expected_machine_cost
|
|
expected_labour_cost = (self.duration_expected / 60) * self.workcenter_id.employee_costs_hour * (self.operation_id.employee_ratio or 1)
|
|
return expected_machine_cost + expected_labour_cost
|
|
|
|
def _get_current_theorical_operation_cost(self, without_employee_cost=False):
|
|
current_machine_cost = super()._get_current_theorical_operation_cost()
|
|
if without_employee_cost:
|
|
return current_machine_cost
|
|
return current_machine_cost + self._get_current_theorical_employee_cost()
|
|
|
|
def _get_current_theorical_employee_cost(self):
|
|
return (self.get_duration() / 60) * self.workcenter_id.employee_costs_hour * (self.operation_id.employee_ratio or 1)
|
|
|
|
def _compute_current_operation_cost(self):
|
|
current_machine_cost = super()._compute_current_operation_cost()
|
|
current_labour_cost = sum(self.time_ids.mapped('total_cost'))
|
|
return current_machine_cost + current_labour_cost
|
|
|
|
def end_all(self):
|
|
self.employee_ids = [Command.clear()]
|
|
return super().end_all()
|
|
|
|
def action_mrp_workorder_dependencies(self, action_name):
|
|
action = self.env['ir.actions.act_window']._for_xml_id('mrp.action_mrp_workorder_%s' % action_name)
|
|
ref = 'mrp_workorder.workcenter_line_gantt_production_dependencies' if action_name == 'production' else 'mrp_workorder.mrp_workorder_view_gantt_dependencies'
|
|
|
|
if self.env.user.has_group('mrp.group_mrp_workorder_dependencies'):
|
|
action['views'] = [(self.env.ref(ref).id, 'gantt')] + [(id, kind) for id, kind in action['views'] if kind != 'gantt']
|
|
return action
|
|
|
|
def _prepare_timeline_vals(self, duration, date_start, date_end=False):
|
|
time_data = super()._prepare_timeline_vals(duration=duration, date_start=date_start, date_end=date_end)
|
|
employee = self._get_connected_employee()
|
|
time_data['employee_id'] = employee.id
|
|
time_data['description'] = _('Time Tracking: %(user)s', user=employee.name)
|
|
return time_data
|
|
|
|
def set_qty_producing(self):
|
|
self.ensure_one()
|
|
self.production_id.set_qty_producing()
|
|
|
|
# Find first uncompleted step of type register production (if it exists) in the chain
|
|
current_check = self.check_ids.filtered(lambda c: not c.previous_check_id)[:1]
|
|
while current_check and current_check.next_check_id and (current_check.test_type != 'register_production' or current_check.quality_state != 'none'):
|
|
current_check = current_check.next_check_id
|
|
|
|
if current_check and current_check.test_type == 'register_production' and current_check.quality_state == 'none':
|
|
# Check exists already, mark as done.
|
|
current_check.action_next()
|
|
elif not any(c.test_type == 'register_production' for c in self.check_ids):
|
|
# Add a register production step for this WO only (at the front of the list)
|
|
first_check = self.check_ids.filtered(lambda c: not c.previous_check_id)
|
|
new_check = self.env['quality.check'].create([{
|
|
'title': _('Register Production'),
|
|
'test_type_id': self.env.ref('mrp_workorder.test_type_register_production').id,
|
|
'workorder_id': self.id,
|
|
'quality_state': 'pass',
|
|
'production_id': self.production_id.id,
|
|
'product_id': self.product_id.id,
|
|
'lot_ids': self.production_id.lot_producing_ids.ids,
|
|
'team_id': self.env['quality.alert.team']._get_quality_team(
|
|
self.env['quality.alert.team']._check_company_domain(
|
|
self.company_id.id or self.env.context.get('default_company_id', self.env.company.id)
|
|
))
|
|
}])
|
|
if first_check:
|
|
new_check._insert_in_chain('before', first_check)
|
|
else:
|
|
self.current_quality_check_id = new_check
|
|
|
|
def _get_connected_employee(self):
|
|
if self.env.context.get('mrp_display'):
|
|
if (self.env.context.get('employee_id')):
|
|
connected_employee_id = self.env.context.get('employee_id')
|
|
else:
|
|
connected_employee_id = self.env['hr.employee'].get_session_owner()
|
|
connected_employee = self.env['hr.employee'].browse(connected_employee_id)
|
|
else:
|
|
if self.employee_assigned_ids:
|
|
connected_employee = self.employee_assigned_ids[0]
|
|
else:
|
|
connected_employee = self.env.user.employee_id
|
|
|
|
return connected_employee
|