416 lines
19 KiB
Python
416 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
from markupsafe import Markup
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.fields import Command
|
|
from odoo.tools import float_compare, float_round, is_html_empty
|
|
|
|
|
|
class QualityPointTest_Type(models.Model):
|
|
_inherit = "quality.point.test_type"
|
|
|
|
allow_registration = fields.Boolean(
|
|
search='_get_domain_from_allow_registration',
|
|
store=False, default=False)
|
|
|
|
def _get_domain_from_allow_registration(self, operator, value):
|
|
# The ORM will optimize the domain leaf before calling the search method:
|
|
# ('field', '=', True) and ('field', '!=', False) become ('field', 'in', OrderedSet([True]))
|
|
# ('field', '=', False) and ('field', '!=', True) become ('field', ' not in', OrderedSet([True]))
|
|
if operator not in ('in', 'not in'):
|
|
return NotImplemented
|
|
if operator == 'in':
|
|
return []
|
|
else:
|
|
return [('technical_name', 'not in', ['register_production', 'register_byproducts', 'register_consumed_materials', 'print_label'])]
|
|
|
|
|
|
class MrpRoutingWorkcenter(models.Model):
|
|
_inherit = "mrp.routing.workcenter"
|
|
|
|
quality_point_ids = fields.One2many('quality.point', 'operation_id', copy=True)
|
|
quality_point_count = fields.Integer('Instructions', compute='_compute_quality_point_count')
|
|
|
|
employee_ratio = fields.Float("Employee Capacity", default=1, help="Number of employees needed to complete operation.")
|
|
|
|
default_picking_type_ids = fields.One2many(comodel_name='stock.picking.type', compute='_compute_default_picking_type_ids')
|
|
|
|
@api.depends('employee_ratio')
|
|
def _compute_cost(self):
|
|
super()._compute_cost()
|
|
for operation in self:
|
|
operation.cost += (operation.time_total / 60.0) * operation.workcenter_id.employee_costs_hour * (operation.employee_ratio or 1)
|
|
|
|
@api.depends('quality_point_ids')
|
|
def _compute_quality_point_count(self):
|
|
read_group_res = self.env['quality.point'].sudo()._read_group(
|
|
[('id', 'in', self.quality_point_ids.ids)],
|
|
['operation_id'], ['__count']
|
|
)
|
|
data = {operation.id: count for operation, count in read_group_res}
|
|
for operation in self:
|
|
operation.quality_point_count = data.get(operation.id, 0)
|
|
|
|
def _compute_default_picking_type_ids(self):
|
|
self.default_picking_type_ids = self.env['stock.picking.type'].search([('code', '=', 'mrp_operation')])
|
|
|
|
def write(self, vals):
|
|
if 'active' in vals:
|
|
self.with_context(active_test=False).quality_point_ids.write({'active': vals['active']})
|
|
res = super().write(vals)
|
|
if 'bom_id' in vals:
|
|
self.quality_point_ids._change_product_ids_for_bom(self.bom_id)
|
|
return res
|
|
|
|
def copy(self, default=None):
|
|
new_workcenters = super().copy(default)
|
|
if default and "bom_id" in default:
|
|
for new_workcenter in new_workcenters:
|
|
new_workcenter.quality_point_ids._change_product_ids_for_bom(new_workcenter.bom_id)
|
|
return new_workcenters
|
|
|
|
|
|
class QualityPoint(models.Model):
|
|
_inherit = "quality.point"
|
|
|
|
def _default_product_ids(self):
|
|
# Determines a default product from the default operation's BOM.
|
|
operation_id = self.env.context.get('default_operation_id')
|
|
if operation_id:
|
|
bom = self.env['mrp.routing.workcenter'].browse(operation_id).bom_id
|
|
return bom.product_id.ids if bom.product_id else bom.product_tmpl_id.product_variant_id.ids
|
|
|
|
is_workorder_step = fields.Boolean(compute='_compute_is_workorder_step')
|
|
operation_id = fields.Many2one(
|
|
'mrp.routing.workcenter', 'Step', check_company=True, index='btree_not_null')
|
|
bom_id = fields.Many2one(related='operation_id.bom_id')
|
|
bom_active = fields.Boolean('Related Bill of Material Active', related='bom_id.active')
|
|
component_ids = fields.One2many('product.product', compute='_compute_component_ids')
|
|
product_ids = fields.Many2many(
|
|
default=_default_product_ids,
|
|
domain="operation_id and [('id', 'in', bom_product_ids)] or [('type', '=', 'consu'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
|
|
bom_product_ids = fields.One2many('product.product', compute="_compute_bom_product_ids")
|
|
test_type_id = fields.Many2one(
|
|
'quality.point.test_type',
|
|
domain="[('allow_registration', '=', operation_id and is_workorder_step)]")
|
|
test_report_type = fields.Selection([('pdf', 'PDF'), ('zpl', 'ZPL')], string="Report Type", default="pdf", required=True)
|
|
worksheet_document = fields.Binary('Image/PDF')
|
|
# Used with type register_consumed_materials the product raw to encode.
|
|
component_id = fields.Many2one('product.product', 'Product To Register', check_company=True)
|
|
|
|
@api.onchange('bom_product_ids', 'is_workorder_step')
|
|
def _onchange_bom_product_ids(self):
|
|
if self.is_workorder_step and self.bom_product_ids:
|
|
self.product_ids = self.product_ids & self.bom_product_ids
|
|
self.product_category_ids = False
|
|
|
|
@api.depends('bom_id.product_id', 'bom_id.product_tmpl_id.product_variant_ids', 'is_workorder_step', 'bom_id')
|
|
def _compute_bom_product_ids(self):
|
|
self.bom_product_ids = False
|
|
points_for_workorder_step = self.filtered(lambda p: p.operation_id and p.bom_id)
|
|
for point in points_for_workorder_step:
|
|
bom_product_ids = point.bom_id.product_id or point.bom_id.product_tmpl_id.product_variant_ids
|
|
point.bom_product_ids = bom_product_ids.filtered(lambda p: not p.company_id or p.company_id == point.company_id._origin)
|
|
|
|
@api.depends('product_ids', 'test_type_id', 'is_workorder_step')
|
|
def _compute_component_ids(self):
|
|
self.component_ids = False
|
|
for point in self:
|
|
if point.test_type == 'register_byproducts':
|
|
point.component_ids = point.bom_id.byproduct_ids.product_id
|
|
elif point.test_type == 'register_consumed_materials':
|
|
bom_products = point.bom_id.product_id or point.bom_id.product_tmpl_id.product_variant_ids
|
|
# If product_ids is set the step will exist only for these product variant then we can filter out for the bom explode
|
|
if point.product_ids:
|
|
bom_products &= point.product_ids._origin
|
|
|
|
component_product_ids = set()
|
|
for product in bom_products:
|
|
dummy, lines_done = point.bom_id.explode(product, 1.0)
|
|
component_product_ids |= {line[0].product_id.id for line in lines_done}
|
|
point.component_ids = self.env['product.product'].browse(component_product_ids)
|
|
|
|
@api.depends('operation_id', 'picking_type_ids')
|
|
def _compute_is_workorder_step(self):
|
|
for quality_point in self:
|
|
quality_point.is_workorder_step = quality_point.picking_type_ids and\
|
|
all(pt.code == 'mrp_operation' for pt in quality_point.picking_type_ids)
|
|
|
|
@api.depends('operation_id')
|
|
def _compute_show_failure_location(self):
|
|
super()._compute_show_failure_location()
|
|
for point in self:
|
|
point.show_failure_location = point.show_failure_location and not point.operation_id
|
|
|
|
def _change_product_ids_for_bom(self, bom_id):
|
|
products = bom_id.product_id or bom_id.product_tmpl_id.product_variant_ids
|
|
self.product_ids = [Command.set(products.ids)]
|
|
|
|
@api.onchange('operation_id')
|
|
def _onchange_operation_id(self):
|
|
if self.operation_id:
|
|
self._change_product_ids_for_bom(self.bom_id)
|
|
|
|
@api.onchange('test_type_id')
|
|
def _onchange_test_type_id(self):
|
|
if self.test_type_id.technical_name not in ('register_byproducts', 'register_consumed_materials'):
|
|
self.component_id = False
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if 'picking_type_ids' in vals:
|
|
self.filtered(lambda p: not p.is_workorder_step).operation_id = False
|
|
return res
|
|
|
|
def action_view_worksheet_document(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Worksheet Preview"),
|
|
'res_model': 'quality.point',
|
|
'res_id': self.id,
|
|
'views': [(self.env.ref('mrp_workorder.quality_point_worksheet_document_preview_form').id, 'form')],
|
|
'target': 'new',
|
|
}
|
|
|
|
|
|
class QualityAlert(models.Model):
|
|
_inherit = "quality.alert"
|
|
|
|
workorder_id = fields.Many2one('mrp.workorder', 'Operation', check_company=True, index='btree_not_null')
|
|
workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center', check_company=True)
|
|
production_id = fields.Many2one('mrp.production', "Production Order", check_company=True, index='btree_not_null')
|
|
|
|
|
|
class QualityCheck(models.Model):
|
|
_inherit = "quality.check"
|
|
|
|
workorder_id = fields.Many2one(
|
|
'mrp.workorder', 'Operation', check_company=True, index='btree_not_null')
|
|
workcenter_id = fields.Many2one('mrp.workcenter', related='workorder_id.workcenter_id')
|
|
production_id = fields.Many2one(
|
|
'mrp.production', 'Production Order', check_company=True, index='btree_not_null')
|
|
product_tracking = fields.Selection(related='production_id.product_tracking')
|
|
|
|
# doubly linked chain for tablet view navigation
|
|
next_check_id = fields.Many2one('quality.check')
|
|
previous_check_id = fields.Many2one('quality.check')
|
|
|
|
# For components registration
|
|
move_id = fields.Many2one(
|
|
'stock.move', 'Stock Move', check_company=True, index='btree_not_null')
|
|
component_id = fields.Many2one(
|
|
'product.product', 'Component', check_company=True)
|
|
component_barcode = fields.Char(related='component_id.barcode')
|
|
component_uom_id = fields.Many2one('uom.uom', related='move_id.product_uom', string='Component Unit', readonly=True)
|
|
|
|
finished_lot_ids = fields.Many2many('stock.lot', 'Finished Lot/Serial', related='production_id.lot_producing_ids')
|
|
component_tracking = fields.Selection(related='component_id.tracking', string="Is Component Tracked")
|
|
|
|
# Workorder specific fields
|
|
is_user_working = fields.Boolean(related="workorder_id.is_user_working")
|
|
consumption = fields.Selection(related="workorder_id.consumption")
|
|
working_state = fields.Selection(related="workorder_id.working_state")
|
|
is_deleted = fields.Boolean('Deleted in production') # TODO DELETE in MASTER
|
|
|
|
# Computed fields
|
|
title = fields.Char('Title', compute='_compute_title')
|
|
result = fields.Char('Result', compute='_compute_result')
|
|
|
|
# Used to group the steps belonging to the same production
|
|
# We use a float because it is actually filled in by the produced quantity at the step creation.
|
|
finished_product_sequence = fields.Float('Finished Product Sequence Number')
|
|
worksheet_document = fields.Binary('Image/PDF')
|
|
|
|
# Employees
|
|
employee_id = fields.Many2one('hr.employee', string="Employee")
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
quality_points = {value['point_id'] for value in vals_list if value.get('point_id')}
|
|
quality_points_component_mapping = {
|
|
point.id: point.component_id.id
|
|
for point in self.env['quality.point'].browse(list(quality_points))
|
|
if point.component_id
|
|
}
|
|
for value in vals_list:
|
|
if value.get('component_id') or not value.get('point_id'):
|
|
continue
|
|
component = quality_points_component_mapping.get(value['point_id'])
|
|
if component:
|
|
value['component_id'] = component
|
|
return super().create(vals_list)
|
|
|
|
@api.depends('test_type_id', 'component_id', 'component_id.name', 'workorder_id', 'workorder_id.name')
|
|
def _compute_title(self):
|
|
super()._compute_title()
|
|
for check in self:
|
|
if not check.title and not check.point_id and check.component_id:
|
|
check.title = '{} "{}"'.format(check.test_type_id.display_name, check.component_id.name or check.workorder_id.name)
|
|
|
|
@api.depends('point_id', 'quality_state', 'component_id', 'component_uom_id', 'lot_ids')
|
|
def _compute_result(self):
|
|
for check in self:
|
|
if check.quality_state == 'none':
|
|
check.result = ''
|
|
else:
|
|
check.result = check._get_check_result()
|
|
|
|
def copy(self, default=None):
|
|
default = dict(default or {})
|
|
new_checks = super().copy(default)
|
|
|
|
for old_check, new_check in zip(self, new_checks):
|
|
# only insert into chain if the quality check is linked with another quality check
|
|
if old_check.previous_check_id or old_check.next_check_id:
|
|
new_check._insert_in_chain('after', old_check)
|
|
return new_checks
|
|
|
|
def _get_check_result(self):
|
|
if self.test_type in ('register_consumed_materials', 'register_byproducts'):
|
|
if len(self.lot_ids) == 1:
|
|
return f'{self.component_id.name} - {self.lot_ids[0].name}, {self.component_uom_id.name}'
|
|
return f'{self.component_id.name}, {self.component_uom_id.name}'
|
|
return ''
|
|
|
|
def action_print(self):
|
|
quality_point_id = self.point_id
|
|
report_type = quality_point_id.test_report_type
|
|
|
|
if self.product_id.tracking == 'none':
|
|
res = self._get_product_label_action(report_type)
|
|
else:
|
|
if self.workorder_id.finished_lot_ids:
|
|
res = self._get_lot_label_action(report_type)
|
|
else:
|
|
raise UserError(_('You did not set a lot/serial number for '
|
|
'the final product'))
|
|
|
|
# The button goes immediately to the next step
|
|
res['next_check_id'] = self._next()
|
|
return res
|
|
|
|
def _get_print_qty(self):
|
|
uom_unit = self.env.ref('uom.product_uom_unit')
|
|
if self.product_tracking != 'serial' and self.product_id.uom_id._has_common_reference(uom_unit):
|
|
qty = int(self.workorder_id.qty_producing) or int(self.workorder_id.qty_production)
|
|
else:
|
|
qty = 1
|
|
return qty
|
|
|
|
def _get_product_label_action(self, report_type):
|
|
self.ensure_one()
|
|
xml_id = 'product.action_open_label_layout'
|
|
wizard_action = self.env['ir.actions.act_window']._for_xml_id(xml_id)
|
|
wizard_action['context'] = {'default_product_ids': self.product_id.ids}
|
|
if report_type == 'zpl':
|
|
wizard_action['context']['default_print_format'] = 'zpl'
|
|
wizard_action['id'] = self.env.ref(xml_id).id
|
|
return wizard_action
|
|
|
|
def _get_lot_label_action(self, report_type):
|
|
qty = self._get_print_qty()
|
|
|
|
if report_type == 'zpl':
|
|
xml_id = 'stock.label_lot_template'
|
|
else:
|
|
xml_id = 'stock.action_report_lot_label'
|
|
res = self.env.ref(xml_id).report_action(self.workorder_id.finished_lot_ids.ids * qty)
|
|
res['id'] = self.env.ref(xml_id).id
|
|
return res
|
|
|
|
def action_next(self):
|
|
self.ensure_one()
|
|
return {'next_check_id': self._next()}
|
|
|
|
def add_check_in_chain(self, notify_bom=True):
|
|
self.ensure_one()
|
|
if self.workorder_id.current_quality_check_id:
|
|
self._insert_in_chain('after', self.workorder_id.current_quality_check_id)
|
|
else:
|
|
self.workorder_id.current_quality_check_id = self
|
|
if notify_bom and self.workorder_id.production_id.bom_id:
|
|
body = _('BoM feedback (%(production)s - %(operation)s)', production=self.workorder_id.production_id.name, operation=self.workorder_id.operation_id.name)
|
|
body += Markup("<br/>%s") % _("New Step suggested by %(user_name)s", user_name=self.env.user.name)
|
|
if self.title:
|
|
body += Markup("<br/><b>%s</b> %s") % (_("Title:"), self.title)
|
|
if self.note and not is_html_empty(self.note):
|
|
body += Markup("<br/><b>%s</b>%s") % (_("Instruction:"), self.note)
|
|
attachments = []
|
|
if self.worksheet_document:
|
|
attachments = [('document', base64.b64decode(self.worksheet_document))]
|
|
self.workorder_id.production_id.bom_id.message_post(body=body, attachments=attachments)
|
|
|
|
def _next(self):
|
|
""" This function:
|
|
|
|
- first: fullfill related move line with right lot and validated quantity.
|
|
- second: Generate new quality check for remaining quantity and link them to the original check.
|
|
- third: Pass to the next check or return a failure message.
|
|
"""
|
|
self.ensure_one()
|
|
self.workorder_id.current_quality_check_id = self.id
|
|
|
|
if self.test_type == 'picture' and not self.picture:
|
|
raise UserError(_('Please upload a picture.'))
|
|
|
|
if self.quality_state == 'none':
|
|
self.do_pass()
|
|
|
|
return self.workorder_id._change_quality_check(position='next')
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_repair_chain(self):
|
|
for check in self:
|
|
prev = check.previous_check_id
|
|
nxt = check.next_check_id
|
|
if prev and nxt:
|
|
prev.next_check_id = nxt
|
|
nxt.previous_check_id = prev
|
|
elif prev:
|
|
prev.next_check_id = False
|
|
elif nxt:
|
|
nxt.previous_check_id = False
|
|
|
|
def _insert_in_chain(self, position, relative):
|
|
"""Insert the quality check `self` in a chain of quality checks.
|
|
|
|
The chain of quality checks is implicitly given by the `relative` argument,
|
|
i.e. by following its `previous_check_id` and `next_check_id` fields.
|
|
|
|
:param position: Where we need to insert `self` according to `relative`
|
|
:type position: string
|
|
:param relative: Where we need to insert `self` in the chain
|
|
:type relative: A `quality.check` record.
|
|
"""
|
|
self.ensure_one()
|
|
assert position in ['before', 'after']
|
|
if position == 'before':
|
|
new_previous = relative.previous_check_id
|
|
self.next_check_id = relative
|
|
self.previous_check_id = new_previous
|
|
new_previous.next_check_id = self
|
|
relative.previous_check_id = self
|
|
else:
|
|
new_next = relative.next_check_id
|
|
self.next_check_id = new_next
|
|
self.previous_check_id = relative
|
|
new_next.previous_check_id = self
|
|
relative.next_check_id = self
|
|
|
|
def do_pass(self):
|
|
res = super().do_pass()
|
|
for check in self:
|
|
if check.move_id:
|
|
check.move_id.picked = True
|
|
if check.workorder_id:
|
|
if check.workorder_id.employee_id:
|
|
check.employee_id = self.workorder_id.employee_id
|
|
if check.workorder_id.state == 'ready':
|
|
check.workorder_id.button_start(bypass=True)
|
|
return res
|