[IMP] hr,hr_payroll: plan automation on triggers
Automate activites linked to plan on certain triggers. To automate onboarding and offboarding processes, we can now define plans that will activate upon triggers, such as employee creation, departure (archive), contract start or contract end. Manual plans are still possible. Upon activation of a plan, a message will be added to the employee's chatter with the name of the plan. More options have been added related to when to schedule the plan's activities. The blocking mechanism has been removed (the plan would not launch if an activity could not be started, due to lack of information), failed activities will now be logged into the employee's chatter. The list and kanban employee views have also been updated to include info such as first contract date and activity information. Task ID: 2489095 closes odoo/odoo#68451 Related: odoo/enterprise#17343 Related: odoo/upgrade#2314 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
committed by
Yannick Tivisse
parent
2cc481d1a6
commit
864595901e
@@ -53,6 +53,8 @@
|
||||
|
||||
<record id="onboarding_plan" model='hr.plan'>
|
||||
<field name="name">Onboarding</field>
|
||||
<field name="plan_type">onboarding</field>
|
||||
<field name="trigger">manual</field>
|
||||
<field name="plan_activity_type_ids" eval="[(6, 0, [
|
||||
ref('hr.onboarding_setup_it_materials'),
|
||||
ref('hr.onboarding_plan_training'),
|
||||
@@ -62,6 +64,8 @@
|
||||
|
||||
<record id="offboarding_plan" model='hr.plan'>
|
||||
<field name="name">Offboarding</field>
|
||||
<field name="plan_type">offboarding</field>
|
||||
<field name="trigger">manual</field>
|
||||
<field name="plan_activity_type_ids" eval="[(6, 0, [
|
||||
ref('hr.offboarding_setup_compute_out_delais'),
|
||||
ref('hr.offboarding_take_back_hr_materials'),
|
||||
|
||||
@@ -6,10 +6,11 @@ from random import choice
|
||||
from string import digits
|
||||
from werkzeug.urls import url_encode
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv.query import Query
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
from odoo.exceptions import ValidationError, AccessError, UserError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.misc import format_date
|
||||
|
||||
@@ -281,17 +282,20 @@ class HrEmployeePrivate(models.Model):
|
||||
vals.update(self._sync_user(user, bool(vals.get('image_1920'))))
|
||||
vals['name'] = vals.get('name', user.name)
|
||||
employee = super(HrEmployeePrivate, self).create(vals)
|
||||
url = '/web#%s' % url_encode({
|
||||
'action': 'hr.plan_wizard_action',
|
||||
'active_id': employee.id,
|
||||
'active_model': 'hr.employee',
|
||||
'menu_id': self.env.ref('hr.menu_hr_root').id,
|
||||
})
|
||||
employee._message_log(body=_('<b>Congratulations!</b> May I recommend you to setup an <a href="%s">onboarding plan?</a>') % (url))
|
||||
if employee.department_id:
|
||||
self.env['mail.channel'].sudo().search([
|
||||
('subscription_department_ids', 'in', employee.department_id.id)
|
||||
])._subscribe_users_automatically()
|
||||
# Launch onboarding plans
|
||||
if not employee._launch_plans_from_trigger(trigger='employee_creation'):
|
||||
# Keep the recommend message if no plans are launched
|
||||
url = '/web#%s' % url_encode({
|
||||
'action': 'hr.plan_wizard_action',
|
||||
'active_id': employee.id,
|
||||
'active_model': 'hr.employee',
|
||||
'menu_id': self.env.ref('hr.menu_hr_root').id,
|
||||
})
|
||||
employee._message_log(body=_('<b>Congratulations!</b> May I recommend you to setup an <a href="%s">onboarding plan?</a>') % (url))
|
||||
return employee
|
||||
|
||||
def write(self, vals):
|
||||
@@ -336,9 +340,9 @@ class HrEmployeePrivate(models.Model):
|
||||
archived_addresses = unarchived_employees.mapped('address_home_id').filtered(lambda addr: not addr.active)
|
||||
archived_addresses.toggle_active()
|
||||
|
||||
# Empty links to this employees (example: manager, coach, time off responsible, ...)
|
||||
archived_employees = self.filtered(lambda e: not e.active)
|
||||
if archived_employees:
|
||||
# Empty links to this employees (example: manager, coach, time off responsible, ...)
|
||||
employee_fields_to_empty = self._get_employee_m2o_to_empty_on_archived_employees()
|
||||
user_fields_to_empty = self._get_user_m2o_to_empty_on_archived_employees()
|
||||
employee_domain = [[(field, 'in', archived_employees.ids)] for field in employee_fields_to_empty]
|
||||
@@ -352,6 +356,9 @@ class HrEmployeePrivate(models.Model):
|
||||
if employee[field] in archived_employees.user_id:
|
||||
employee[field] = False
|
||||
|
||||
# Launch automatic offboarding plans
|
||||
archived_employees._launch_plans_from_trigger(trigger='employee_archive')
|
||||
|
||||
if len(self) == 1 and not self.active:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
@@ -410,6 +417,71 @@ class HrEmployeePrivate(models.Model):
|
||||
self = self.with_user(real_user)
|
||||
return self
|
||||
|
||||
def _launch_plans_from_trigger(self, trigger):
|
||||
'''
|
||||
Launches all plans for given trigger
|
||||
|
||||
Returns False if no plans are launched, True otherwise
|
||||
'''
|
||||
plan_ids = self.env['hr.plan'].search([('trigger', '=', trigger)])
|
||||
if not plan_ids or not self:
|
||||
return False
|
||||
#Group plans and employees by company id
|
||||
plans_per_company = defaultdict(lambda: self.env['hr.plan'])
|
||||
for plan_id in plan_ids:
|
||||
plans_per_company[plan_id.company_id.id] |= plan_id
|
||||
employees_per_company = defaultdict(lambda: self.env['hr.employee'])
|
||||
for employee_id in self:
|
||||
employees_per_company[employee_id.company_id.id] |= employee_id
|
||||
#Launch the plans
|
||||
for company_id in employees_per_company:
|
||||
employees_per_company[company_id]._launch_plan(plans_per_company[company_id])
|
||||
return True
|
||||
|
||||
def _launch_plan(self, plan_ids):
|
||||
'''
|
||||
Launch all given plans
|
||||
'''
|
||||
for employee_id in self:
|
||||
for plan_id in plan_ids:
|
||||
employee_id._message_log(
|
||||
body=_('Plan %s has been launched.', plan_id.name),
|
||||
)
|
||||
errors = []
|
||||
for activity_type in plan_id.plan_activity_type_ids:
|
||||
responsible = False
|
||||
try:
|
||||
responsible = activity_type.get_responsible_id(employee_id)
|
||||
except UserError as error:
|
||||
errors.append(_(
|
||||
'Warning ! The step "%(name)s: %(summary)s" assigned to %(responsible)s '
|
||||
'could not be started because: "%(error)s"',
|
||||
name=activity_type.activity_type_id.name,
|
||||
summary=activity_type.summary,
|
||||
responsible=activity_type.responsible,
|
||||
error=str(error)
|
||||
))
|
||||
continue
|
||||
|
||||
if self.env['hr.employee'].with_user(responsible).check_access_rights('read', raise_exception=False):
|
||||
if activity_type.deadline_type == 'default':
|
||||
date_deadline = self.env['mail.activity']._calculate_date_deadline(activity_type.activity_type_id)
|
||||
elif activity_type.deadline_type == 'plan_active':
|
||||
date_deadline = fields.Date.context_today(self)
|
||||
elif activity_type.deadline_type == 'trigger_offset':
|
||||
date_deadline = fields.Date.add(fields.Date.context_today(self), days=activity_type.deadline_days)
|
||||
|
||||
employee_id.activity_schedule(
|
||||
activity_type_id=activity_type.activity_type_id.id,
|
||||
summary=activity_type.summary,
|
||||
note=activity_type.note,
|
||||
user_id=responsible.id,
|
||||
date_deadline=date_deadline,
|
||||
)
|
||||
employee_id._message_log(
|
||||
body='<br/>'.join(errors),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Messaging
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,13 @@ class HrPlanActivityType(models.Model):
|
||||
_description = 'Plan activity type'
|
||||
_rec_name = 'summary'
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'check_deadline_days', 'CHECK (COALESCE(deadline_days) >= 0)',
|
||||
'Days deadline must be positive.'
|
||||
),
|
||||
]
|
||||
|
||||
activity_type_id = fields.Many2one(
|
||||
'mail.activity.type', 'Activity Type',
|
||||
default=lambda self: self.env.ref('mail.mail_activity_data_todo'),
|
||||
@@ -22,8 +29,24 @@ class HrPlanActivityType(models.Model):
|
||||
('manager', 'Manager'),
|
||||
('employee', 'Employee'),
|
||||
('other', 'Other')], default='employee', string='Responsible', required=True)
|
||||
responsible_id = fields.Many2one('res.users', 'Responsible Person', help='Specific responsible of activity if not linked to the employee.')
|
||||
responsible_id = fields.Many2one('res.users', 'Name', help='Specific responsible of activity if not linked to the employee.')
|
||||
note = fields.Html('Note')
|
||||
deadline_type = fields.Selection(
|
||||
[
|
||||
('default', 'Default value'),
|
||||
('plan_active', "At plan's activation"),
|
||||
('trigger_offset', 'Days after activation trigger'),
|
||||
],
|
||||
string='Activity Deadline',
|
||||
default='default',
|
||||
required=True,
|
||||
)
|
||||
deadline_days = fields.Integer(string='Days Deadline')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.depends('activity_type_id')
|
||||
def _compute_default_summary(self):
|
||||
@@ -62,3 +85,68 @@ class HrPlan(models.Model):
|
||||
name = fields.Char('Name', required=True)
|
||||
plan_activity_type_ids = fields.Many2many('hr.plan.activity.type', string='Activities')
|
||||
active = fields.Boolean(default=True)
|
||||
plan_type = fields.Selection(
|
||||
[
|
||||
('onboarding', 'Onboarding'),
|
||||
('offboarding', 'Offboarding'),
|
||||
('other', 'Other'),
|
||||
], string='Type', default='onboarding', required=True,
|
||||
)
|
||||
trigger_onboarding = fields.Selection(
|
||||
[
|
||||
('manual', 'Manual'),
|
||||
('employee_creation', 'Employee Creation'),
|
||||
], compute='_compute_triggers', inverse='_inverse_triggers',
|
||||
required=True, readonly=False,
|
||||
)
|
||||
trigger_offboarding = fields.Selection(
|
||||
[
|
||||
('manual', 'Manual'),
|
||||
('employee_archive', 'Archived Employee'),
|
||||
], compute='_compute_triggers', inverse='_inverse_triggers',
|
||||
required=True, readonly=False,
|
||||
)
|
||||
trigger_other = fields.Selection(
|
||||
[
|
||||
('manual', 'Manual'),
|
||||
], compute='_compute_triggers', inverse='_inverse_triggers',
|
||||
required=True, readonly=False,
|
||||
)
|
||||
trigger = fields.Char(default='manual', compute='_compute_trigger', store=True)
|
||||
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
||||
|
||||
@api.depends('trigger')
|
||||
def _compute_triggers(self):
|
||||
trigger_types = {'trigger_onboarding', 'trigger_offboarding', 'trigger_other'}
|
||||
type_to_trigger = {
|
||||
'onboarding': 'trigger_onboarding',
|
||||
'offboarding': 'trigger_offboarding',
|
||||
'other': 'trigger_other',
|
||||
}
|
||||
for record in self:
|
||||
#trigger for active
|
||||
record[type_to_trigger[record.plan_type]] = record.trigger or 'manual'
|
||||
#'manual' for all others
|
||||
for disabled_trigger in trigger_types - {type_to_trigger[record.plan_type]}:
|
||||
record[disabled_trigger] = 'manual'
|
||||
|
||||
def _inverse_triggers(self):
|
||||
type_to_trigger = {
|
||||
'onboarding': 'trigger_onboarding',
|
||||
'offboarding': 'trigger_offboarding',
|
||||
'other': 'trigger_other',
|
||||
}
|
||||
for record in self:
|
||||
# or 'manual' required is for trigger_other since it can not be changed it's always False here
|
||||
record.trigger = record[type_to_trigger[record.plan_type]] or 'manual'
|
||||
|
||||
@api.depends('plan_type')
|
||||
def _compute_trigger(self):
|
||||
# In case only plan_type changes
|
||||
type_to_trigger = {
|
||||
'onboarding': 'trigger_onboarding',
|
||||
'offboarding': 'trigger_offboarding',
|
||||
'other': 'trigger_other',
|
||||
}
|
||||
for record in self:
|
||||
record.trigger = record[type_to_trigger[record.plan_type]] or 'manual'
|
||||
|
||||
@@ -50,5 +50,17 @@
|
||||
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_comp_rule" model="ir.rule">
|
||||
<field name="name">Plan multi company rule</field>
|
||||
<field name="model_id" ref="model_hr_plan"/>
|
||||
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_activity_type_comp_rule" model="ir.rule">
|
||||
<field name="name">Plan activity type multi company rule</field>
|
||||
<field name="model_id" ref="model_hr_plan_activity_type"/>
|
||||
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -23,4 +23,9 @@
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.hr_activity_container {
|
||||
margin-bottom: 2px;
|
||||
margin-right: -3px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -221,6 +221,9 @@
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="work_phone" class="o_force_ltr" readonly="1"/>
|
||||
<field name="work_email"/>
|
||||
<field name="activity_ids" widget="list_activity"/>
|
||||
<field name="activity_user_id" optional="hide" string="Activity by" widget="many2one_avatar_user"/>
|
||||
<field name="activity_date_deadline" widget="remaining_days" options="{'allow_order': '1'}"/>
|
||||
<field name="company_id" groups="base.group_multi_company" readonly="1"/>
|
||||
<field name="department_id"/>
|
||||
<field name="job_id"/>
|
||||
@@ -303,6 +306,9 @@
|
||||
<div class="oe_kanban_bottom_left"/>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<a title="Chat" icon="fa-comments" href="#" class="ml8 o_employee_chat_btn" attrs="{'invisible': [('user_id','=', False)]}" role="button"><i class="fa fa-comments"/></a>
|
||||
<div class="hr_activity_container">
|
||||
<field name="activity_ids" widget="kanban_activity"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Planning">
|
||||
<field name="name"/>
|
||||
<field name="plan_type"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -37,12 +38,23 @@
|
||||
<field name="name" placeholder="e.g. Onboarding"/>
|
||||
</h1>
|
||||
</div>
|
||||
<field name="active" invisible="1"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="plan_type" widget="radio"/>
|
||||
<field name="trigger" invisible="1"/>
|
||||
<field name="trigger_onboarding" string="Activation Trigger" attrs="{'invisible': [('plan_type', '!=', 'onboarding')]}"/>
|
||||
<field name="trigger_offboarding" string="Activation Trigger" attrs="{'invisible': [('plan_type', '!=', 'offboarding')]}"/>
|
||||
<field name="trigger_other" string="Activation Trigger" attrs="{'invisible': [('plan_type', '!=', 'other')], 'readonly': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Activities">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="plan_activity_type_ids" nolabel="1">
|
||||
<tree editable="bottom">
|
||||
<field name="activity_type_id"/>
|
||||
<field name="summary"/>
|
||||
<field name="deadline_type"/>
|
||||
<field name="deadline_days" attrs="{'readonly': [('deadline_type', '!=', 'trigger_offset')]}"/>
|
||||
<field name="responsible"/>
|
||||
<field name="responsible_id" attrs="{'readonly': [('responsible', '!=', 'other')]}"/>
|
||||
</tree>
|
||||
@@ -60,6 +72,8 @@
|
||||
<tree string="Activities">
|
||||
<field name="activity_type_id"/>
|
||||
<field name="summary"/>
|
||||
<field name="deadline_type"/>
|
||||
<field name="deadline_days" attrs="{'invisible': [('deadline_type', '!=', 'trigger_offset')]}"/>
|
||||
<field name="responsible"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -74,6 +88,8 @@
|
||||
<group>
|
||||
<field name="activity_type_id"/>
|
||||
<field name="summary"/>
|
||||
<field name="deadline_type"/>
|
||||
<field name="deadline_days" attrs="{'invisible': [('deadline_type', '!=', 'trigger_offset')]}"/>
|
||||
<field name="responsible"/>
|
||||
<field name="responsible_id" attrs="{'invisible': [('responsible', '!=', 'other')]}"/>
|
||||
<field name="note"/>
|
||||
@@ -88,12 +104,22 @@
|
||||
<field name="res_model">hr.plan</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="hr_plan_view_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new plan
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_plan_activity_type_action" model="ir.actions.act_window">
|
||||
<field name="name">Planning Types</field>
|
||||
<field name="res_model">hr.plan.activity.type</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new planning activity
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrPlanWizard(models.TransientModel):
|
||||
_name = 'hr.plan.wizard'
|
||||
_description = 'Plan Wizard'
|
||||
|
||||
plan_id = fields.Many2one('hr.plan', default=lambda self: self.env['hr.plan'].search([], limit=1))
|
||||
plan_id = fields.Many2one(
|
||||
'hr.plan',
|
||||
default=lambda self: self.env['hr.plan'].search([('trigger', '=', 'manual')], limit=1),
|
||||
domain="[('trigger', '=', 'manual')]",
|
||||
required=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Employee', required=True,
|
||||
default=lambda self: self.env.context.get('active_id', None),
|
||||
)
|
||||
|
||||
def action_launch(self):
|
||||
for activity_type in self.plan_id.plan_activity_type_ids:
|
||||
responsible = activity_type.get_responsible_id(self.employee_id)
|
||||
|
||||
if self.env['hr.employee'].with_user(responsible).check_access_rights('read', raise_exception=False):
|
||||
date_deadline = self.env['mail.activity']._calculate_date_deadline(activity_type.activity_type_id)
|
||||
self.employee_id.activity_schedule(
|
||||
activity_type_id=activity_type.activity_type_id.id,
|
||||
summary=activity_type.summary,
|
||||
note=activity_type.note,
|
||||
user_id=responsible.id,
|
||||
date_deadline=date_deadline
|
||||
)
|
||||
self.ensure_one()
|
||||
self.employee_id._launch_plan(self.plan_id)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
|
||||
@@ -25,6 +25,7 @@ You can assign several contracts per employee.
|
||||
'data/hr_contract_data.xml',
|
||||
'report/hr_contract_history_report_views.xml',
|
||||
'views/hr_contract_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/resource_calendar_views.xml',
|
||||
'wizard/hr_departure_wizard_views.xml',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_employee_tree" model="ir.ui.view">
|
||||
<field name="name">hr.employee.tree</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_tree"></field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='work_email']" position="after">
|
||||
<field name="first_contract_date" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user