# Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import defaultdict from random import randint import ast from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError, ValidationError from odoo.fields import Command, Domain class MrpEcoType(models.Model): _name = 'mrp.eco.type' _description = 'ECO Type' _inherit = ['mail.alias.mixin', 'mail.thread'] _order = "sequence, id" name = fields.Char('Name', required=True, translate=True) sequence = fields.Integer('Sequence') nb_ecos = fields.Integer('ECOs', compute='_compute_nb') nb_approvals = fields.Integer('Waiting Approvals', compute='_compute_nb') nb_approvals_my = fields.Integer('Waiting my Approvals', compute='_compute_nb') nb_validation = fields.Integer('To Apply', compute='_compute_nb') color = fields.Integer('Color', default=1) stage_ids = fields.Many2many('mrp.eco.stage', 'mrp_eco_stage_type_rel', 'type_id', 'stage_id', string='Stages') def _compute_nb(self): # TDE FIXME: this seems not good for performances, to check (replace by _read_group later on) MrpEco = self.env['mrp.eco'] for eco_type in self: eco_type.nb_ecos = MrpEco.search_count([ ('type_id', 'in', eco_type.ids), ('state', '!=', 'done') ]) eco_type.nb_validation = MrpEco.search_count([ ('type_id', 'in', eco_type.ids), ('stage_id.allow_apply_change', '=', True), ('state', '=', 'progress') ]) eco_type.nb_approvals = MrpEco.search_count([ ('type_id', 'in', eco_type.ids), ('approval_ids.status', '=', 'none') ]) approvals_waiting_for_me = self.env['mrp.eco.approval'].search_read([ ('eco_id.type_id', 'in', eco_type.ids), ('eco_id.active', '=', True), ('status', '=', 'none'), ('required_user_ids', '=', self.env.user.id) ], fields=['eco_id']) num_eco = len({app_data['eco_id'] for app_data in approvals_waiting_for_me}) eco_type.nb_approvals_my = num_eco def _alias_get_creation_values(self): values = super(MrpEcoType, self)._alias_get_creation_values() values['alias_model_id'] = self.env['ir.model']._get('mrp.eco').id if self.id: values['alias_defaults'] = defaults = ast.literal_eval(self.alias_defaults or "{}") defaults['type_id'] = self.id return values class MrpEcoApprovalTemplate(models.Model): _name = 'mrp.eco.approval.template' _order = "sequence" _description = 'ECO Approval Template' name = fields.Char('Role', required=True) sequence = fields.Integer('Sequence') approval_type = fields.Selection([ ('optional', 'Approves, but the approval is optional'), ('mandatory', 'Is required to approve'), ('comment', 'Comments only')], 'Approval Type', default='mandatory', required=True, index=True) user_ids = fields.Many2many('res.users', string='Users', domain=lambda self: [('all_group_ids', 'in', self.env.ref('mrp_plm.group_plm_user').id)], required=True) stage_id = fields.Many2one('mrp.eco.stage', 'Stage', required=True, index=True) @api.constrains('user_ids', 'stage_id') def _check_unique_user_stage(self): domain = Domain.OR([ [('id', '!=', record.id), ('stage_id', '=', record.stage_id.id), ('user_ids', 'in', record.user_ids.ids)] for record in self.filtered('user_ids') ]) duplicate = self.env['mrp.eco.approval.template'].search_count(domain, limit=1) if duplicate: raise ValidationError(_('A user cannot be assigned more than once to the same stage')) class MrpEcoApproval(models.Model): _name = 'mrp.eco.approval' _description = 'ECO Approval' _order = 'approval_date desc' eco_id = fields.Many2one( 'mrp.eco', 'ECO', ondelete='cascade', required=True, index=True) approval_template_id = fields.Many2one( 'mrp.eco.approval.template', 'Template', ondelete='cascade', required=True) name = fields.Char('Role', related='approval_template_id.name') user_id = fields.Many2one( 'res.users', 'Approved by') required_user_ids = fields.Many2many( 'res.users', string='Requested Users', related='approval_template_id.user_ids', readonly=False) template_stage_id = fields.Many2one( 'mrp.eco.stage', 'Approval Stage', related='approval_template_id.stage_id') eco_stage_id = fields.Many2one( 'mrp.eco.stage', 'ECO Stage', related='eco_id.stage_id') status = fields.Selection([ ('none', 'Not Yet'), ('comment', 'Commented'), ('approved', 'Approved'), ('rejected', 'Rejected')], string='Status', default='none', required=True, index=True) approval_date = fields.Datetime('Approval Date') is_closed = fields.Boolean() is_approved = fields.Boolean( compute='_compute_is_approved', store=True) is_rejected = fields.Boolean( compute='_compute_is_rejected', store=True) awaiting_my_validation = fields.Boolean( compute='_compute_awaiting_my_validation', search='_search_awaiting_my_validation') @api.depends('status', 'approval_template_id.approval_type') def _compute_is_approved(self): for rec in self: if rec.approval_template_id.approval_type == 'mandatory': rec.is_approved = rec.status == 'approved' else: rec.is_approved = True @api.depends('status', 'approval_template_id.approval_type') def _compute_is_rejected(self): for rec in self: if rec.approval_template_id.approval_type == 'mandatory': rec.is_rejected = rec.status == 'rejected' else: rec.is_rejected = False @api.depends('status', 'approval_template_id.approval_type') def _compute_awaiting_my_validation(self): # trigger the search method and return a domain where approval ids satisfying the conditions in the search method awaiting_validation_approval = self.search([('id', 'in', self.ids), ('awaiting_my_validation', '=', True)]) # set awaiting_my_validation values for approvals awaiting_validation_approval.awaiting_my_validation = True (self - awaiting_validation_approval).awaiting_my_validation = False def _search_awaiting_my_validation(self, operator, value): if operator != 'in': return NotImplemented return [('required_user_ids', 'in', self.env.uid), ('approval_template_id.approval_type', 'in', ('mandatory', 'optional')), ('status', '!=', 'approved'), ('is_closed', '=', False)] class MrpEcoStage(models.Model): _name = 'mrp.eco.stage' _description = 'ECO Stage' _order = "sequence, id" _fold_name = 'folded' @api.model def _get_sequence(self): others = self.search([('sequence', '!=', False)], order='sequence desc', limit=1) if others: return (others[0].sequence or 0) + 1 return 1 name = fields.Char('Name', required=True, translate=True) sequence = fields.Integer('Sequence', default=_get_sequence) folded = fields.Boolean('Folded in kanban view') allow_apply_change = fields.Boolean(string='Allow to apply changes', help='Allow to apply changes from this stage.') final_stage = fields.Boolean(string='Final Stage', help='Once the changes are applied, the ECOs will be moved to this stage.') type_ids = fields.Many2many('mrp.eco.type', 'mrp_eco_stage_type_rel', 'stage_id', 'type_id', string='Types', required=True) approval_template_ids = fields.One2many('mrp.eco.approval.template', 'stage_id', 'Approvals') approval_roles = fields.Char('Approval Roles', compute='_compute_approvals', store=True) is_blocking = fields.Boolean('Blocking Stage', compute='_compute_is_blocking', store=True) legend_blocked = fields.Char( 'Red Kanban Label', default=lambda s: s.env._('Blocked'), translate=True, required=True, help='Override the default value displayed for the blocked state for kanban selection, when the ECO is in that stage.') legend_done = fields.Char( 'Green Kanban Label', default=lambda s: s.env._('Ready'), translate=True, required=True, help='Override the default value displayed for the done state for kanban selection, when the ECO is in that stage.') legend_normal = fields.Char( 'Grey Kanban Label', default=lambda s: s.env._('In Progress'), translate=True, required=True, help='Override the default value displayed for the normal state for kanban selection, when the ECO is in that stage.') description = fields.Text(help="Description and tooltips of the stage states.") @api.depends('approval_template_ids.name') def _compute_approvals(self): for rec in self: rec.approval_roles = ', '.join(rec.approval_template_ids.mapped('name')) @api.depends('approval_template_ids.approval_type') def _compute_is_blocking(self): for rec in self: rec.is_blocking = any(template.approval_type == 'mandatory' for template in rec.approval_template_ids) class MrpEco(models.Model): _name = 'mrp.eco' _description = 'Engineering Change Order (ECO)' _inherit = ['mail.thread.cc', 'mail.activity.mixin'] @api.model def _get_type_selection(self): return [ ('bom', _('Bill of Materials')), ('product', _('Product Only'))] name = fields.Char('Reference', copy=False, required=True) user_id = fields.Many2one('res.users', 'Responsible', default=lambda self: self.env.user, tracking=True, check_company=True) type_id = fields.Many2one('mrp.eco.type', 'Type', required=True) stage_id = fields.Many2one( 'mrp.eco.stage', 'Stage', ondelete='restrict', copy=False, domain="[('type_ids', 'in', type_id)]", group_expand='_read_group_stage_ids', tracking=True, default=lambda self: self.env['mrp.eco.stage'].search([('type_ids', 'in', self.env.context.get('default_type_id'))], limit=1)) company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company) tag_ids = fields.Many2many('mrp.eco.tag', string='Tags') priority = fields.Selection([ ('0', 'Normal'), ('1', 'High')], string='Priority', tracking=True, index=True) note = fields.Html('Note') effectivity = fields.Selection([ ('asap', 'As soon as possible'), ('date', 'At Date')], string='Effective', # Is this English ? compute='_compute_effectivity', inverse='_set_effectivity', store=True, help='Date on which the changes should be applied. For reference only.') effectivity_date = fields.Datetime('Effective Date', tracking=True, help="For reference only.") approval_ids = fields.One2many('mrp.eco.approval', 'eco_id', 'Approvals', help='Approvals by stage') state = fields.Selection([ ('confirmed', 'To Do'), ('progress', 'In Progress'), ('rebase', 'Rebase'), ('conflict', 'Conflict'), ('done', 'Done')], string='Status', copy=False, default='confirmed', readonly=True, required=True, index=True) user_can_approve = fields.Boolean( 'Can Approve', compute='_compute_user_approval', help='Technical field to check if approval by current user is required') user_can_reject = fields.Boolean( 'Can Reject', compute='_compute_user_approval', help='Technical field to check if reject by current user is possible') kanban_state = fields.Selection([ ('normal', 'In Progress'), ('done', 'Approved'), ('blocked', 'Blocked')], string='Kanban State', copy=False, compute='_compute_kanban_state', store=True, readonly=False) legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', related_sudo=False) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', related_sudo=False) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', related_sudo=False) kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State Label', tracking=True) allow_change_kanban_state = fields.Boolean( 'Allow Change Kanban State', compute='_compute_allow_change_kanban_state') allow_change_stage = fields.Boolean( 'Allow Change Stage', compute='_compute_allow_change_stage') allow_apply_change = fields.Boolean( 'Show Apply Change', compute='_compute_allow_apply_change') product_tmpl_id = fields.Many2one('product.template', "Product", check_company=True, index='btree_not_null') production_id = fields.Many2one( 'mrp.production', string='Manufacturing Orders', readonly=True, copy=False, index='btree_not_null') type = fields.Selection(selection=_get_type_selection, string='Apply on', default='bom', required=True) bom_id = fields.Many2one( 'mrp.bom', "Bill of Materials", domain="[('product_tmpl_id', '=', product_tmpl_id)]", check_company=True) # Should at least have bom or routing on which it is applied? new_bom_id = fields.Many2one( 'mrp.bom', 'New Bill of Materials', copy=False, index='btree_not_null') new_bom_revision = fields.Integer('BoM Revision', related='new_bom_id.version', readonly=False) will_update_version = fields.Boolean( "Update Version", default=True, help="If unchecked, the version of the product/BoM will remain unchanged once the ECO is applied") bom_change_ids = fields.One2many( 'mrp.eco.bom.change', 'eco_id', string="ECO BoM Changes", compute='_compute_bom_change_ids', help='Difference between old BoM and new BoM revision', store=True) bom_change_ids_on_line = fields.One2many( 'mrp.eco.bom.change', 'eco_id', string="ECO BoM Changes - Component", domain=[('bom_line_id', '!=', False)]) bom_change_ids_on_byproduct = fields.One2many( 'mrp.eco.bom.change', 'eco_id', string="ECO BoM Changes - By-Product", domain=[('byproduct_id', '!=', False)]) bom_rebase_ids = fields.One2many('mrp.eco.bom.change', 'rebase_id', string="BoM Rebase") routing_change_ids = fields.One2many( 'mrp.eco.routing.change', 'eco_id', string="ECO Routing Changes", compute='_compute_routing_change_ids', help='Difference between old operation and new operation revision', store=True) document_count = fields.Integer('# Attachments', compute='_compute_attachments') document_ids = fields.One2many( 'product.document', 'res_id', string='Attachments', bypass_search_access=True, domain=lambda self: [('res_model', '=', self._name)]) displayed_image_id = fields.Many2one( 'product.document', 'Displayed Image', domain="[('res_model', '=', 'mrp.eco'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]") displayed_image_attachment_id = fields.Many2one('ir.attachment', related='displayed_image_id.ir_attachment_id', readonly=False) color = fields.Integer('Color') active = fields.Boolean('Active', default=True, help="If the active field is set to False, it will allow you to hide the engineering change order without removing it.") current_bom_id = fields.Many2one('mrp.bom', string="New Bom") previous_change_ids = fields.One2many('mrp.eco.bom.change', 'eco_rebase_id', string="Previous ECO Changes", compute='_compute_previous_bom_change', store=True) def _compute_attachments(self): for p in self: p.document_count = len(p.document_ids) @api.depends('effectivity_date') def _compute_effectivity(self): for eco in self: eco.effectivity = 'date' if eco.effectivity_date else 'asap' def _set_effectivity(self): for eco in self: if eco.effectivity == 'asap': eco.effectivity_date = False def _is_conflict(self, new_bom_lines, changes=None): # Find rebase lines having conflict or not. reb_conflicts = self.env['mrp.eco.bom.change'] for reb_line in changes: new_line = new_bom_lines.get(reb_line.product_id, None) if new_line and (reb_line.old_operation_id, reb_line.old_uom_id, reb_line.old_product_qty) != (new_line.operation_id, new_line.product_uom_id, new_line.product_qty): reb_conflicts |= reb_line reb_conflicts.write({'conflict': True}) return reb_conflicts def _get_difference_bom_lines(self, old_bom, new_bom): # Return difference lines from two bill of material. new_bom_commands = [Command.clear()] old_bom_lines = {line._get_sync_values(): line for line in old_bom.bom_line_ids} if new_bom: for new_line in new_bom.bom_line_ids: old_line = old_bom_lines.get(new_line._get_sync_values()) if old_line: old_bom_lines.pop(old_line._get_sync_values()) if new_line.product_uom_id != old_line.product_uom_id or tools.float_compare(new_line.product_qty, old_line.product_qty, precision_rounding=new_line.product_uom_id.rounding): new_bom_commands += [(0, 0, { 'change_type': 'update', 'product_id': new_line.product_id.id, 'old_product_qty': old_line.product_qty, 'new_product_qty': new_line.product_qty, 'old_uom_id': old_line.product_uom_id.id, 'new_uom_id': new_line.product_uom_id.id, 'old_operation_id': old_line.operation_id.id, 'new_operation_id': new_line.operation_id.id, 'bom_line_id': new_line.id })] else: new_bom_commands += [(0, 0, { 'change_type': 'add', 'product_id': new_line.product_id.id, 'new_product_qty': new_line.product_qty, 'new_uom_id': new_line.product_uom_id.id, 'new_operation_id': new_line.operation_id.id, 'bom_line_id': new_line.id, })] for old_line in old_bom_lines.values(): new_bom_commands += [(0, 0, { 'change_type': 'remove', 'product_id': old_line.product_id.id, 'old_product_qty': old_line.product_qty, 'old_uom_id': old_line.product_uom_id.id, 'old_operation_id': old_line.operation_id.id, 'bom_line_id': old_line.id, })] old_byproducts = {byproduct._get_sync_values(): byproduct for byproduct in old_bom.byproduct_ids} if new_bom: for new_byproduct in new_bom.byproduct_ids: old_byproduct = old_byproducts.get(new_byproduct._get_sync_values()) if old_byproduct: old_byproducts.pop(old_byproduct._get_sync_values()) if new_byproduct.product_uom_id != old_byproduct.product_uom_id or tools.float_compare(new_byproduct.product_qty, old_byproduct.product_qty, precision_rounding=new_byproduct.product_uom_id.rounding): new_bom_commands += [Command.create({ 'change_type': 'update', 'product_id': new_byproduct.product_id.id, 'old_product_qty': old_byproduct.product_qty, 'new_product_qty': new_byproduct.product_qty, 'old_uom_id': old_byproduct.product_uom_id.id, 'new_uom_id': new_byproduct.product_uom_id.id, 'old_operation_id': old_byproduct.operation_id.id, 'new_operation_id': new_byproduct.operation_id.id, 'byproduct_id': new_byproduct.id })] else: new_bom_commands += [Command.create({ 'change_type': 'add', 'product_id': new_byproduct.product_id.id, 'new_product_qty': new_byproduct.product_qty, 'new_uom_id': new_byproduct.product_uom_id.id, 'new_operation_id': new_byproduct.operation_id.id, 'byproduct_id': new_byproduct.id, })] for old_byproduct in old_byproducts.values(): new_bom_commands += [Command.create({ 'change_type': 'remove', 'product_id': old_byproduct.product_id.id, 'old_product_qty': old_byproduct.product_qty, 'old_uom_id': old_byproduct.product_uom_id.id, 'old_operation_id': old_byproduct.operation_id.id, 'byproduct_id': old_byproduct.id, })] return new_bom_commands def rebase(self, old_bom_lines, new_bom_lines, rebase_lines): """ This method will apply changes in new revision of BoM old_bom_lines : Previous BoM or Old BoM version lines. new_bom_lines : New BoM version lines. rebase_lines : Changes done in previous version """ for reb_line in rebase_lines: new_bom_line = new_bom_lines.get(reb_line.product_id, None) if new_bom_line: if new_bom_line.product_qty + reb_line.upd_product_qty > 0.0: # Update line if it exist in new bom. new_bom_line.write({ 'product_qty': new_bom_line.product_qty + reb_line.upd_product_qty, 'product_uom_id': reb_line.new_uom_id.id, 'operation_id': reb_line.new_operation_id.id, }) else: # Unlink lines if old bom removed lines new_bom_line.unlink() else: # Add bom line in new bom for rebase. old_line = old_bom_lines.get(reb_line.product_id, None) if old_line: old_line.copy({'bom_id': self.new_bom_id.id}) return True def apply_rebase(self): """ Apply rebase changes in new version of BoM """ self.ensure_one() # Rebase logic applied.. vals = {'state': 'progress'} if self.bom_rebase_ids: new_bom_lines = {line.product_id: line for line in self.new_bom_id.bom_line_ids} if self._is_conflict(new_bom_lines, self.bom_rebase_ids): return self.write({'state': 'conflict'}) else: old_bom_lines = {line.product_id: line for line in self.bom_id.bom_line_ids} self.rebase(old_bom_lines, new_bom_lines, self.bom_rebase_ids) # Remove all rebase line of current eco. self.bom_rebase_ids.unlink() if self.previous_change_ids: new_bom_lines = {line.product_id: line for line in self.new_bom_id.bom_line_ids} if self._is_conflict(new_bom_lines, self.previous_change_ids): return self.write({'state': 'conflict'}) else: new_activated_bom_lines = {line.product_id: line for line in self.current_bom_id.bom_line_ids} self.rebase(new_activated_bom_lines, new_bom_lines, self.previous_change_ids) # Remove all rebase line of current eco. self.previous_change_ids.unlink() if self.current_bom_id: self.new_bom_id.write({'version': self.will_update_version and self.current_bom_id.version + 1 or self.current_bom_id.version, 'previous_bom_id': self.current_bom_id.id}) vals.update({'bom_id': self.current_bom_id.id, 'current_bom_id': False}) self.message_post(body=_('Successfully Rebased!')) return self.write(vals) @api.depends('bom_id.bom_line_ids', 'new_bom_id.bom_line_ids', 'new_bom_id.bom_line_ids.product_qty', 'new_bom_id.bom_line_ids.product_uom_id', 'new_bom_id.bom_line_ids.operation_id', 'bom_id.byproduct_ids', 'new_bom_id.byproduct_ids', 'new_bom_id.byproduct_ids.product_qty', 'new_bom_id.bom_line_ids.operation_id') def _compute_bom_change_ids(self): # Compute difference between old bom and new bom revision. for eco in self: eco.bom_change_ids = eco._get_difference_bom_lines(eco.bom_id, eco.new_bom_id) @api.depends('bom_id.bom_line_ids', 'current_bom_id.bom_line_ids', 'current_bom_id.bom_line_ids.product_qty', 'current_bom_id.bom_line_ids.product_uom_id', 'current_bom_id.bom_line_ids.operation_id') def _compute_previous_bom_change(self): for eco in self: if eco.current_bom_id: # Compute difference between old bom and newly activated bom. eco.previous_change_ids = eco._get_difference_bom_lines(eco.bom_id, eco.current_bom_id) else: eco.previous_change_ids = False @api.depends('bom_id.operation_ids', 'bom_id.operation_ids.active', 'bom_id.operation_ids.time_mode', 'bom_id.operation_ids.time_mode_batch', 'bom_id.operation_ids.time_cycle_manual', 'new_bom_id.operation_ids', 'new_bom_id.operation_ids.active', 'new_bom_id.operation_ids.time_mode', 'new_bom_id.operation_ids.time_mode_batch', 'new_bom_id.operation_ids.time_cycle_manual') def _compute_routing_change_ids(self): for rec in self: if rec.state == 'confirmed' or rec.type == 'product': continue new_routing_commands = [Command.clear()] old_routing_lines = defaultdict(lambda: self.env['mrp.routing.workcenter']) # Two operations could have the same values so we save them with the same key for op in rec.bom_id.operation_ids: old_routing_lines[op._get_sync_values()] |= op if rec.new_bom_id and rec.bom_id: for operation in rec.new_bom_id.operation_ids: key = (operation._get_sync_values()) old_op = old_routing_lines[key][:1] if old_op: old_routing_lines[key] -= old_op if old_op.time_mode != operation.time_mode or \ old_op.time_mode_batch != operation.time_mode_batch or \ tools.float_compare(old_op.time_cycle_manual, operation.time_cycle_manual, 2) != 0: new_routing_commands += [Command.create({ 'change_type': 'update', 'workcenter_id': operation.workcenter_id.id, 'upd_time_mode': old_op.time_mode + ' -> ' + operation.time_mode if operation.time_mode != old_op.time_mode else '', 'upd_time_mode_batch': operation.time_mode_batch - old_op.time_mode_batch if operation.time_mode == 'auto' else 0, 'upd_time_cycle_manual': operation.time_cycle_manual - old_op.time_cycle_manual if operation.time_mode == 'manual' else 0, 'operation_id': operation.id, })] new_routing_commands += self._prepare_detailed_change_commands(operation, old_op) else: new_routing_commands += [Command.create({ 'change_type': 'add', 'workcenter_id': operation.workcenter_id.id, 'upd_time_mode': operation.time_mode, 'upd_time_mode_batch': operation.time_mode_batch, 'upd_time_cycle_manual': operation.time_cycle_manual, 'operation_id': operation.id, })] new_routing_commands += self._prepare_detailed_change_commands(operation, None) for old_ops in old_routing_lines.values(): for old_op in old_ops: new_routing_commands += [Command.create({ 'change_type': 'remove', 'workcenter_id': old_op.workcenter_id.id, 'operation_id': old_op.id, })] rec.routing_change_ids = new_routing_commands def _prepare_detailed_change_commands(self, new, old): """Necessary for overrides to track change of quality checks""" return [] def _compute_user_approval(self): for eco in self: is_required_approval = eco.stage_id.approval_template_ids.filtered(lambda x: x.approval_type in ('mandatory', 'optional') and self.env.user in x.user_ids) user_approvals = eco.approval_ids.filtered(lambda x: x.template_stage_id == eco.stage_id and x.user_id == self.env.user and not x.is_closed) last_approval = user_approvals.sorted(lambda a : a.create_date, reverse=True)[:1] eco.user_can_approve = is_required_approval and not last_approval.is_approved eco.user_can_reject = is_required_approval and not last_approval.is_rejected @api.depends('stage_id', 'approval_ids.is_approved', 'approval_ids.is_rejected') def _compute_kanban_state(self): """ State of ECO is based on the state of approvals for the current stage. """ for rec in self: approvals = rec.approval_ids.filtered(lambda app: app.template_stage_id == rec.stage_id and not app.is_closed) if not approvals: rec.kanban_state = 'normal' elif all(approval.is_approved for approval in approvals): rec.kanban_state = 'done' elif any(approval.is_rejected for approval in approvals): rec.kanban_state = 'blocked' else: rec.kanban_state = 'normal' @api.depends('kanban_state', 'stage_id', 'approval_ids') def _compute_allow_change_stage(self): for rec in self: approvals = rec.approval_ids.filtered(lambda app: app.template_stage_id == rec.stage_id) if approvals: rec.allow_change_stage = rec.kanban_state == 'done' else: rec.allow_change_stage = rec.kanban_state in ['normal', 'done'] @api.depends('state', 'stage_id.allow_apply_change') def _compute_allow_apply_change(self): for rec in self: rec.allow_apply_change = rec.stage_id.allow_apply_change and rec.state in ('confirmed', 'progress') @api.depends('stage_id.approval_template_ids') def _compute_allow_change_kanban_state(self): for rec in self: rec.allow_change_kanban_state = not rec.stage_id.approval_template_ids @api.depends('stage_id', 'kanban_state') def _compute_kanban_state_label(self): for eco in self: if eco.kanban_state == 'normal': eco.kanban_state_label = eco.legend_normal elif eco.kanban_state == 'blocked': eco.kanban_state_label = eco.legend_blocked else: eco.kanban_state_label = eco.legend_done @api.onchange('product_tmpl_id') def onchange_product_tmpl_id(self): if self.product_tmpl_id.bom_ids: bom_product_tmpl = self.bom_id.product_tmpl_id or self.bom_id.product_id.product_tmpl_id if bom_product_tmpl != self.product_tmpl_id: self.bom_id = self.product_tmpl_id.bom_ids.ids[0] @api.onchange('type_id') def onchange_type_id(self): self.stage_id = self.env['mrp.eco.stage'].search([('type_ids', 'in', self.type_id.id)], limit=1).id @api.model_create_multi def create(self, vals_list): for vals in vals_list: prefix = self.env['ir.sequence'].next_by_code('mrp.eco') or '' vals['name'] = '%s%s' % (prefix and '%s: ' % prefix or '', vals.get('name', '')) ecos = super().create(vals_list) ecos._create_approvals() return ecos def write(self, vals): if vals.get('stage_id'): newstage = self.env['mrp.eco.stage'].browse(vals['stage_id']) # raise exception only if we increase the stage, not on decrease for eco in self: if eco.stage_id and ((newstage.sequence, newstage.id) > (eco.stage_id.sequence, eco.stage_id.id)): if not eco.allow_change_stage: raise UserError(_('You cannot change the stage, as approvals are still required.')) has_blocking_stages = self.env['mrp.eco.stage'].search_count([ ('sequence', '>=', eco.stage_id.sequence), ('sequence', '<=', newstage.sequence), ('type_ids', 'in', eco.type_id.id), ('id', 'not in', [eco.stage_id.id] + [vals['stage_id']]), ('is_blocking', '=', True)]) if has_blocking_stages: raise UserError(_('You cannot change the stage, as approvals are required in the process.')) if eco.stage_id != newstage: eco.approval_ids.filtered(lambda x: x.status != 'none').write({'is_closed': True}) eco.approval_ids.filtered(lambda x: x.status == 'none').unlink() if 'displayed_image_attachment_id' in vals: doc = False if vals['displayed_image_attachment_id']: doc = self.env['product.document'].search([('ir_attachment_id', '=', vals['displayed_image_attachment_id'])]) if not doc: doc = self.env['product.document'].create([{'ir_attachment_id': vals['displayed_image_attachment_id']}]) vals.pop('displayed_image_attachment_id') vals['displayed_image_id'] = doc if 'will_update_version' in vals: for eco in self: if eco.new_bom_id and eco.state != 'done' and eco.will_update_version != vals['will_update_version']: eco.new_bom_id.version += 1 if vals['will_update_version'] else -1 if vals.get('type') == 'product': vals['bom_id'] = False res = super(MrpEco, self).write(vals) if vals.get('stage_id'): self._create_approvals() return res @api.model def _read_group_stage_ids(self, stages, domain): """ Read group customization in order to display all the stages of the ECO type in the Kanban view, even if there is no ECO in that stage """ search_domain = [] if self.env.context.get('default_type_ids'): search_domain = [('type_ids', 'in', self.env.context['default_type_ids'])] stage_ids = stages.sudo()._search(search_domain, order=stages._order) return stages.browse(stage_ids) def message_post(self, **kwargs): message = super(MrpEco, self).message_post(**kwargs) if message.message_type == 'comment' and message.author_id == self.env.user.partner_id: # should use message_values to avoid a read for eco in self: for approval in eco.approval_ids.filtered(lambda app: app.template_stage_id == eco.stage_id and app.status == 'none' and app.approval_template_id.approval_type == 'comment'): if self.env.user in approval.approval_template_id.user_ids: approval.write({ 'status': 'comment', 'user_id': self.env.uid }) return message def _create_approvals(self): approval_vals = [] activity_vals = [] for eco in self: for approval_template in eco.stage_id.approval_template_ids: approval = eco.approval_ids.filtered(lambda app: app.approval_template_id == approval_template and not app.is_closed) if not approval: approval_vals.append({ 'eco_id': eco.id, 'approval_template_id': approval_template.id, }) for user in approval_template.user_ids: # TDE TODO: link to eco approval record ? activity_vals.append({ 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, 'summary': _('ECO Approval'), 'user_id': user.id, 'res_id': eco.id, 'res_model_id': self.env.ref('mrp_plm.model_mrp_eco').id, }) self.env['mrp.eco.approval'].create(approval_vals) self.env['mail.activity'].create(activity_vals) def _create_or_update_approval(self, status): for eco in self: for approval_template in eco.stage_id.approval_template_ids.filtered(lambda a: self.env.user in a.user_ids): approvals = eco.approval_ids.filtered(lambda x: x.approval_template_id == approval_template and not x.is_closed) none_approvals = approvals.filtered(lambda a: a.status =='none') confirmed_approvals = approvals - none_approvals if none_approvals: none_approvals.write({'status': status, 'user_id': self.env.uid, 'approval_date': fields.Datetime.now()}) confirmed_approvals.write({'is_closed': True}) approval = none_approvals[:1] else: approvals.write({'is_closed': True}) approval = self.env['mrp.eco.approval'].create({ 'eco_id': eco.id, 'approval_template_id': approval_template.id, 'status': status, 'user_id': self.env.uid, 'approval_date': fields.Datetime.now(), }) message = _("%(approval_name)s %(approver_name)s %(approval_status)s this ECO", approval_name=approval.name, approver_name=approval.user_id.name, approval_status=approval.status, ) eco.message_post(body=message, subtype_xmlid='mail.mt_comment') def approve(self): self._create_or_update_approval(status='approved') def reject(self): self._create_or_update_approval(status='rejected') def conflict_resolve(self): self.ensure_one() vals = {'state': 'progress'} if self.current_bom_id: vals.update({'bom_id': self.current_bom_id.id, 'current_bom_id': False}) self.write(vals) # Set previous BoM on new revision and change version of BoM. self.new_bom_id.write({ 'version': self.will_update_version and self.bom_id.version + 1 or self.bom_id.version, 'previous_bom_id': self.bom_id.id }) # Remove all rebase lines. rebase_lines = self.bom_rebase_ids + self.previous_change_ids rebase_lines.unlink() return True def action_new_revision(self): for eco in self: domain = Domain([('res_model', '=', 'product.template'), ('res_id', '=', eco.product_tmpl_id.id)]) if eco.type == 'bom': if eco.production_id: # This ECO was generated from a MO. Uses it MO as base for the revision. eco.new_bom_id = eco.production_id._create_revision_bom(eco.will_update_version) if not eco.new_bom_id: eco.new_bom_id = eco.bom_id.sudo().copy(default={ 'version': eco.bom_id.version + eco.will_update_version, 'active': False, 'previous_bom_id': eco.bom_id.id, }) if eco.bom_id.product_id: domain |= Domain(['&', ('res_model', '=', 'product.product'), ('res_id', 'in', eco.bom_id.product_id.ids)]) else: domain |= Domain(['&', ('res_model', '=', 'product.product'), ('res_id', 'in', self.env["product.product"].search([('product_tmpl_id', 'in', eco.product_tmpl_id.ids)]).ids)]) domain &= Domain('attached_on_mrp', '=', 'bom') else: domain |= Domain(['&', ('res_model', '=', 'product.product'), ('res_id', 'in', self.env["product.product"].search([('product_tmpl_id', 'in', eco.product_tmpl_id.ids)]).ids)]) docs = self.env['product.document'].search(domain) for doc in docs: doc.copy({'res_model': 'mrp.eco', 'res_id': eco.id, 'origin_attachment_id': doc.ir_attachment_id.id}) self.write({'state': 'progress'}) def action_apply(self): self._check_company() eco_need_action = self.env['mrp.eco'] for eco in self: if eco.state == 'done': continue if eco.state == 'rebase': eco.apply_rebase() if eco.allow_apply_change: if eco.type == 'product': eco.product_tmpl_id.version = eco.will_update_version and eco.product_tmpl_id.version + 1 or eco.product_tmpl_id.version else: eco.mapped('new_bom_id').apply_new_version() for attach in eco.with_context(active_test=False).document_ids: origin = attach.origin_attachment_id if not attach.active: origin.unlink() continue if origin._compute_checksum(origin.raw) == origin._compute_checksum(attach.raw): if attach.origin_attachment_id.name != attach.name: attach.origin_attachment_id.name = attach.name if attach.origin_attachment_id.company_id != attach.company_id: attach.origin_attachment_id.company_id = attach.company_id continue new_doc = attach.copy({ 'res_model': 'product.template', 'res_id': eco.product_tmpl_id.id, }) attach.origin_attachment_id = new_doc.ir_attachment_id.id vals = {'state': 'done'} stage_id = eco.env['mrp.eco.stage'].search([ ('final_stage', '=', True), ('type_ids', 'in', eco.type_id.id)], limit=1).id if stage_id: vals['stage_id'] = stage_id eco.write(vals) else: eco_need_action |= eco if eco_need_action: return { 'name': _('Eco'), 'type': 'ir.actions.act_window', 'view_mode': 'list, form', 'views': [[False, 'list'], [False, 'form']], 'res_model': 'mrp.eco', 'target': 'current', 'domain': [('id', 'in', eco_need_action.ids)], 'context': {'search_default_changetoapply': False}, } def action_see_attachments(self): self.ensure_one() attachment_view = self.env.ref('mrp_plm.view_document_file_kanban_mrp_plm') context = { 'default_res_model': self._name, 'default_res_id': self.id, 'default_company_id': self.company_id.id, 'search_default_all': 1, 'create': self.state != 'done', 'edit': self.state != 'done', 'delete': self.state != 'done', } if self.type == 'bom': context['eco_bom'] = True return { 'name': _('Attachments'), 'domain': ['&', ('res_model', '=', self._name), ('res_id', '=', self.id)], 'res_model': 'product.document', 'type': 'ir.actions.act_window', 'view_id': attachment_view.id, 'views': [(attachment_view.id, 'kanban'), (False, 'form')], 'view_mode': 'kanban,list,form', 'help': _('''
Upload files to your ECO, that will be applied to the product later
Use this feature to store any files, like drawings or specifications.
'''), 'limit': 80, 'context': context, } def action_open_production(self): self.ensure_one() action = self.env['ir.actions.act_window']._for_xml_id('mrp.action_mrp_production_form') action["res_id"] = self.production_id.id return action def open_new_bom(self): self.ensure_one() return { 'name': _('Eco BoM'), 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'mrp.bom', 'target': 'current', 'res_id': self.new_bom_id.id, 'context': { 'default_product_tmpl_id': self.product_tmpl_id.id, 'default_product_id': self.product_tmpl_id.product_variant_id.id, 'create': self.state != 'done', 'edit': self.state != 'done', 'delete': self.state != 'done', }, } class MrpEcoBomChange(models.Model): _name = 'mrp.eco.bom.change' _description = 'ECO BoM changes' eco_id = fields.Many2one('mrp.eco', 'Engineering Change', index=True, ondelete='cascade') company_id = fields.Many2one(related='eco_id.company_id') eco_rebase_id = fields.Many2one('mrp.eco', 'ECO Rebase', index='btree_not_null', ondelete='cascade') rebase_id = fields.Many2one('mrp.eco', 'Rebase', ondelete='cascade', index='btree_not_null') change_type = fields.Selection([('add', 'Add'), ('remove', 'Remove'), ('update', 'Update')], string='Type', required=True) product_id = fields.Many2one('product.product', 'Product', required=True) old_uom_id = fields.Many2one('uom.uom', 'Previous Product UoM') new_uom_id = fields.Many2one('uom.uom', 'New Product UoM') old_product_qty = fields.Float('Previous revision quantity', default=0) new_product_qty = fields.Float('New revision quantity', default=0) old_operation_id = fields.Many2one('mrp.routing.workcenter', 'Previous Consumed in Operation') new_operation_id = fields.Many2one('mrp.routing.workcenter', 'New Consumed in Operation') upd_product_qty = fields.Float('Quantity', compute='_compute_upd_product_qty', store=True, digits='Product Unit') uom_change = fields.Char('Unit', compute='_compute_change', compute_sudo=True) operation_change = fields.Char(compute='_compute_change', string='Consumed in Operation', compute_sudo=True) conflict = fields.Boolean() bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line', check_company=True) byproduct_id = fields.Many2one('mrp.bom.byproduct', 'BoM By-Product', check_company=True) @api.depends('new_product_qty', 'old_product_qty') def _compute_upd_product_qty(self): for rec in self: rec.upd_product_qty = rec.new_product_qty - rec.old_product_qty @api.depends('old_operation_id', 'new_operation_id', 'old_uom_id', 'new_uom_id') def _compute_change(self): for rec in self: rec.operation_change = rec.new_operation_id.name if rec.change_type == 'add' else rec.old_operation_id.name rec.uom_change = False if (rec.old_uom_id and rec.new_uom_id) and rec.old_uom_id != rec.new_uom_id: rec.uom_change = rec.old_uom_id.name + ' -> ' + rec.new_uom_id.name if (rec.old_operation_id._get_sync_values() != rec.new_operation_id._get_sync_values()) and rec.change_type == 'update': rec.operation_change = (rec.old_operation_id.name or '') + ' -> ' + (rec.new_operation_id.name or '') def action_open_component_change(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'mrp.bom', 'res_id': self.bom_line_id.bom_id.id, 'view_mode': 'form', 'view_id': self.env.ref('mrp_plm.mrp_bom_view_form_inherit_plm_components').id, } def action_open_byproduct_change(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'mrp.bom', 'res_id': self.byproduct_id.bom_id.id, 'view_mode': 'form', 'view_id': self.env.ref('mrp_plm.mrp_bom_view_form_inherit_plm_byproducts').id, } class MrpEcoRoutingChange(models.Model): _name = 'mrp.eco.routing.change' _description = 'Eco Routing changes' eco_id = fields.Many2one('mrp.eco', 'Engineering Change', ondelete='cascade', required=True, index=True) change_type = fields.Selection([('add', 'Add'), ('remove', 'Remove'), ('update', 'Update')], string='Type', required=True) workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center') upd_time_mode = fields.Char('Mode Change') upd_time_mode_batch = fields.Integer('Batch count Change') upd_time_cycle_manual = fields.Float('Manual Duration Change') operation_id = fields.Many2one('mrp.routing.workcenter', 'Operation Id') operation_name = fields.Char(related='operation_id.name', string='Operation') def action_open_routing_change_operation(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'mrp.routing.workcenter', 'res_id': self.operation_id.id, 'view_mode': 'form', } class MrpEcoTag(models.Model): _name = 'mrp.eco.tag' _description = "ECO Tags" 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) _name_uniq = models.Constraint( 'unique (name)', "Tag name already exists!", )