diff --git a/addons_extensions/hr_payroll/models/hr_payslip_worked_days.py b/addons_extensions/hr_payroll/models/hr_payslip_worked_days.py index 6c96c3cb0..a0991179b 100644 --- a/addons_extensions/hr_payroll/models/hr_payslip_worked_days.py +++ b/addons_extensions/hr_payroll/models/hr_payslip_worked_days.py @@ -44,7 +44,14 @@ class HrPayslipWorkedDays(models.Model): if worked_days.payslip_id.wage_type == "hourly": worked_days.amount = worked_days.payslip_id.contract_id.hourly_wage * worked_days.number_of_hours if worked_days.is_paid else 0 else: - worked_days.amount = worked_days.payslip_id.contract_id.contract_wage * worked_days.number_of_hours / (worked_days.payslip_id.sum_worked_hours or 1) if worked_days.is_paid else 0 + days_count = float((worked_days.payslip_id.date_to - worked_days.payslip_id.date_from).days + 1) + hours_per_day = worked_days.payslip_id._get_worked_day_lines_hours_per_day() + if worked_days.work_entry_type_id.is_leave: + daily_wage = float(worked_days.payslip_id.contract_id.contract_wage / days_count) + worked_days.amount = (daily_wage * round(worked_days.number_of_hours/hours_per_day, 5 if hours_per_day else 0)) if worked_days.is_paid else -(daily_wage * round(worked_days.number_of_hours/hours_per_day, 5)) + else: + days_to_remove = round((worked_days.payslip_id.sum_worked_hours - worked_days.number_of_hours)/hours_per_day, 5) if hours_per_day else 0 + worked_days.amount = worked_days.payslip_id.contract_id.contract_wage-((worked_days.payslip_id.contract_id.contract_wage/ days_count) * (days_to_remove if days_to_remove>0 else 1 or 1)) if worked_days.is_paid else 0 def _is_half_day(self): self.ensure_one() diff --git a/addons_extensions/hr_payroll_holidays/__init__.py b/addons_extensions/hr_payroll_holidays/__init__.py new file mode 100644 index 000000000..b60115f0a --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/__init__.py @@ -0,0 +1,4 @@ +#-*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons_extensions/hr_payroll_holidays/__manifest__.py b/addons_extensions/hr_payroll_holidays/__manifest__.py new file mode 100644 index 000000000..9b0d9e6dd --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/__manifest__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Time Off in Payslips', + 'version': '1.0', + 'category': 'Human Resources/Payroll', + 'sequence': 95, + 'depends': ['hr_work_entry_holidays', 'hr_payroll'], + 'data': [ + 'security/hr_payroll_holidays_security.xml', + 'views/res_config_settings_views.xml', + 'views/hr_leave_views.xml', + 'views/hr_allocation_views.xml', + 'views/hr_payslip_run_views.xml', + 'views/hr_payslip_views.xml', + 'data/mail_activity_data.xml', + 'data/ir_actions_server_data.xml', + 'data/hr_payroll_dashboard_warning_data.xml', + ], + 'demo': [ + 'data/demo.xml', + ], + 'auto_install': True, + 'assets': { + 'web.assets_backend': [ + 'hr_payroll_holidays/static/src/**/*', + ], + 'web.assets_backend_lazy': [ + ], + }, + 'license': 'OEEL-1', +} diff --git a/addons_extensions/hr_payroll_holidays/data/demo.xml b/addons_extensions/hr_payroll_holidays/data/demo.xml new file mode 100644 index 000000000..a52b08785 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/data/demo.xml @@ -0,0 +1,10 @@ + + + + blocked + + + + blocked + + diff --git a/addons_extensions/hr_payroll_holidays/data/hr_payroll_dashboard_warning_data.xml b/addons_extensions/hr_payroll_holidays/data/hr_payroll_dashboard_warning_data.xml new file mode 100644 index 000000000..1c8698149 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/data/hr_payroll_dashboard_warning_data.xml @@ -0,0 +1,58 @@ + + + + + + Time Off To Defer + + +leaves_to_defer = self.env['hr.leave'].search([ + ('payslip_state', '=', 'blocked'), + ('state', '=', 'validate'), + ('employee_company_id', 'in', self.env.companies.ids), +]) +if leaves_to_defer: + warning_count = len(leaves_to_defer) + warning_action = 'hr_payroll_holidays.hr_leave_action_open_to_defer' + + + + + Time Off Without Joined Document + + +leaves_no_document = self.env['hr.leave'].search([ + ('state', 'not in', ['refuse', 'validate']), + ('leave_type_support_document', '=', True), + ('attachment_ids', '=', False), + ('employee_company_id', 'in', self.env.companies.ids), +]) +if leaves_no_document: + warning_count = len(leaves_no_document) + warning_records = leaves_no_document + + + + + Time Off Not Related To An Allocation + + +leaves_no_allocation_ids = [] +employees = self.env['hr.employee'].search([('company_id', 'in', self.env.companies.ids)]) +consumed_leaves = employees._get_consumed_leaves(leave_types=self.env['hr.leave.type'].search([ + ('requires_allocation', '=', 'yes'), + ('allows_negative', '=', False), +]))[1] +for employee in consumed_leaves: + to_recheck_leaves_per_leave_type = consumed_leaves[employee] + for holiday_status_id in to_recheck_leaves_per_leave_type: + for end_dates in to_recheck_leaves_per_leave_type[holiday_status_id]['excess_days']: + leave_id = to_recheck_leaves_per_leave_type[holiday_status_id]['excess_days'][end_dates]['leave_id'] + leaves_no_allocation_ids.append(leave_id) +if leaves_no_allocation_ids: + warning_count = len(leaves_no_allocation_ids) + warning_records = self.env['hr.leave'].search([('id', 'in', leaves_no_allocation_ids)]) + + + + diff --git a/addons_extensions/hr_payroll_holidays/data/ir_actions_server_data.xml b/addons_extensions/hr_payroll_holidays/data/ir_actions_server_data.xml new file mode 100644 index 000000000..3f8e6cf60 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/data/ir_actions_server_data.xml @@ -0,0 +1,28 @@ + + + + + Defer to Next Month + + + list + + code + + records.action_report_to_next_month() + + + + + Mark as deferred + + + list,form + + code + + records.activity_feedback(['hr_payroll_holidays.mail_activity_data_hr_leave_to_defer']) + + + + diff --git a/addons_extensions/hr_payroll_holidays/data/mail_activity_data.xml b/addons_extensions/hr_payroll_holidays/data/mail_activity_data.xml new file mode 100644 index 000000000..2ee3ccc1c --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/data/mail_activity_data.xml @@ -0,0 +1,11 @@ + + + + + Leave to Defer + fa-plane + 100 + hr.leave + + + diff --git a/addons_extensions/hr_payroll_holidays/models/__init__.py b/addons_extensions/hr_payroll_holidays/models/__init__.py new file mode 100644 index 000000000..ad1457ee5 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/__init__.py @@ -0,0 +1,9 @@ +#-*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_company +from . import hr_contract +from . import hr_leave +from . import res_config_settings +from . import hr_payslip +from . import mail_activity diff --git a/addons_extensions/hr_payroll_holidays/models/hr_contract.py b/addons_extensions/hr_payroll_holidays/models/hr_contract.py new file mode 100644 index 000000000..54405e725 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/hr_contract.py @@ -0,0 +1,9 @@ +from odoo import models + +class HrContract(models.Model): + _inherit = 'hr.contract' + + def _get_resource_calendar_leaves(self, start_dt, end_dt): + # prevent leaves that are associated with a blocked payslip to be taken + # into account, as they will be deferred lated. + return super()._get_resource_calendar_leaves(start_dt, end_dt).filtered(lambda l: l.holiday_id.payslip_state != 'blocked') diff --git a/addons_extensions/hr_payroll_holidays/models/hr_leave.py b/addons_extensions/hr_payroll_holidays/models/hr_leave.py new file mode 100644 index 000000000..842fd0a92 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/hr_leave.py @@ -0,0 +1,150 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.fields import Datetime +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + +class HrLeave(models.Model): + _inherit = 'hr.leave' + + payslip_state = fields.Selection([ + ('normal', 'To compute in next payslip'), + ('done', 'Computed in current payslip'), + ('blocked', 'To defer to next payslip')], string='Payslip State', + copy=False, default='normal', required=True, tracking=True) + + def action_validate(self, check_state=True): + # Get employees payslips + all_payslips = self.env['hr.payslip'].sudo().search([ + ('employee_id', 'in', self.mapped('employee_id').ids), + ('state', 'in', ['done', 'paid', 'verify']), + ]).filtered(lambda p: p.is_regular) + done_payslips = all_payslips.filtered(lambda p: p.state in ['done', 'paid']) + waiting_payslips = all_payslips - done_payslips + # Mark Leaves to Defer + for leave in self: + if any( + payslip.employee_id == leave.employee_id \ + and (payslip.date_from <= leave.date_to.date() \ + and payslip.date_to >= leave.date_from.date()) for payslip in done_payslips) \ + and not any(payslip.employee_id == leave.employee_id \ + and (payslip.date_from <= leave.date_to.date() \ + and payslip.date_to >= leave.date_from.date()) for payslip in waiting_payslips): + leave.payslip_state = 'blocked' + res = super().action_validate(check_state=check_state) + self.sudo()._recompute_payslips() + return res + + def action_refuse(self): + res = super().action_refuse() + self.sudo()._recompute_payslips() + return res + + def _action_user_cancel(self, reason): + res = super()._action_user_cancel(reason) + self.sudo()._recompute_payslips() + return res + + def _recompute_payslips(self): + # Recompute draft/waiting payslips + all_payslips = self.env['hr.payslip'].sudo().search([ + ('employee_id', 'in', self.mapped('employee_id').ids), + ('state', 'in', ['draft', 'verify']), + ]).filtered(lambda p: p.is_regular) + draft_payslips = self.env['hr.payslip'] + waiting_payslips = self.env['hr.payslip'] + for leave in self: + for payslip in all_payslips: + if payslip.employee_id == leave.employee_id and (payslip.date_from <= leave.date_to.date() and payslip.date_to >= leave.date_from.date()): + if payslip.state == 'draft': + draft_payslips |= payslip + elif payslip.state == 'verify': + waiting_payslips |= payslip + if draft_payslips: + draft_payslips._compute_worked_days_line_ids() + if waiting_payslips: + waiting_payslips.action_refresh_from_work_entries() + + def _cancel_work_entry_conflict(self): + leaves_to_defer = self.filtered(lambda l: l.payslip_state == 'blocked') + for leave in leaves_to_defer: + leave.activity_schedule( + 'hr_payroll_holidays.mail_activity_data_hr_leave_to_defer', + summary=_('Validated Time Off to Defer'), + note=_('Please create manually the work entry for %s', + leave.employee_id._get_html_link()), + user_id=leave.employee_id.company_id.deferred_time_off_manager.id or self.env.ref('base.user_admin').id) + return super(HrLeave, self - leaves_to_defer)._cancel_work_entry_conflict() + + def activity_feedback(self, act_type_xmlids, user_id=None, feedback=None): + if 'hr_payroll_holidays.mail_activity_data_hr_leave_to_defer' in act_type_xmlids: + self.write({'payslip_state': 'done'}) + return super().activity_feedback(act_type_xmlids, user_id=user_id, feedback=feedback) + + def action_report_to_next_month(self): + for leave in self: + if not leave.employee_id or leave.payslip_state != 'blocked': + raise UserError(_('Only an employee time off to defer can be reported to next month')) + if (leave.date_to.year - leave.date_from.year) * 12 + leave.date_to.month - leave.date_from.month > 1: + raise UserError(_('The time off %s can not be reported because it is defined over more than 2 months', leave.display_name)) + leave_work_entries = self.env['hr.work.entry'].search([ + ('employee_id', '=', leave.employee_id.id), + ('company_id', '=', self.env.company.id), + ('date_start', '>=', leave.date_from), + ('date_stop', '<=', leave.date_to), + ]) + next_month_work_entries = self.env['hr.work.entry'].search([ + ('employee_id', '=', leave.employee_id.id), + ('company_id', '=', self.env.company.id), + ('state', '=', 'draft'), + ('date_start', '>=', Datetime.to_datetime(leave.date_from + relativedelta(day=1, months=1))), + ('date_stop', '<=', datetime.combine(Datetime.to_datetime(leave.date_to + relativedelta(day=31, months=1)), datetime.max.time())) + ]) + if not next_month_work_entries: + raise UserError(_('The next month work entries are not generated yet or are validated already for time off %s', leave.display_name)) + if not leave_work_entries: + raise UserError(_('There is no work entries linked to this time off to report')) + for work_entry in leave_work_entries: + found = False + for next_work_entry in next_month_work_entries: + if next_work_entry.work_entry_type_id.code != "WORK100": + continue + if not float_compare(next_work_entry.duration, work_entry.duration, 2): + next_work_entry.work_entry_type_id = leave.holiday_status_id.work_entry_type_id + found = True + break + if not found: + raise UserError(_('Not enough attendance work entries to report the time off %s. Please make the operation manually', leave.display_name)) + # Should change payslip_state to 'done' at the same time + self.activity_feedback(['hr_payroll_holidays.mail_activity_data_hr_leave_to_defer']) + + def _check_uncovered_by_validated_payslip(self): + payslips = self.env['hr.payslip'].sudo().search([ + ('employee_id', 'in', self.employee_id.ids), + ('date_from', '<=', max(self.mapped('date_to'))), + ('date_to', '>=', min(self.mapped('date_from'))), + ('state', 'in', ['done', 'paid']), + ]) + + for leave in self: + if any( + p.employee_id == leave.employee_id and + p.date_from <= leave.date_to.date() and + p.date_to >= leave.date_from.date() and + p.is_regular + for p in payslips + ): + raise UserError(_("The pay of the month is already validated with this day included. If you need to adapt, please refer to HR.")) + + def write(self, vals): + if vals.get('active') and self._check_uncovered_by_validated_payslip(): + self._check_uncovered_by_validated_payslip() + return super().write(vals) + + @api.ondelete(at_uninstall=False) + def _unlink_if_no_payslip(self): + self._check_uncovered_by_validated_payslip() diff --git a/addons_extensions/hr_payroll_holidays/models/hr_payslip.py b/addons_extensions/hr_payroll_holidays/models/hr_payslip.py new file mode 100644 index 000000000..0a800cbd7 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/hr_payslip.py @@ -0,0 +1,30 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import ValidationError +from datetime import datetime + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + def compute_sheet(self): + if self.env.context.get('salary_simulation'): + return super().compute_sheet() + if self.filtered(lambda p: p.is_regular): + employees = self.mapped('employee_id') + leaves = self.env['hr.leave'].search([ + ('employee_id', 'in', employees.ids), + ('state', '!=', 'refuse'), + ]) + leaves_to_defer = leaves.filtered(lambda l: l.payslip_state == 'blocked') + if leaves_to_defer: + raise ValidationError(_( + 'There is some remaining time off to defer for these employees: \n\n %s', + ', '.join(e.display_name for e in leaves_to_defer.mapped('employee_id')))) + dates = self.mapped('date_to') + max_date = datetime.combine(max(dates), datetime.max.time()) + leaves_to_green = leaves.filtered(lambda l: l.payslip_state != 'blocked' and l.date_to <= max_date) + leaves_to_green.write({'payslip_state': 'done'}) + return super().compute_sheet() diff --git a/addons_extensions/hr_payroll_holidays/models/mail_activity.py b/addons_extensions/hr_payroll_holidays/models/mail_activity.py new file mode 100644 index 000000000..3c2a3b4c8 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/mail_activity.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class MailActivity(models.Model): + _inherit = 'mail.activity' + + def _action_done(self, feedback=False, attachment_ids=None): + leave_activities = self.filtered(lambda act: act.res_model == 'hr.leave' and act.res_id) + if leave_activities: + type_to_defer_id = self.env['ir.model.data']._xmlid_to_res_id( + 'hr_payroll_holidays.mail_activity_data_hr_leave_to_defer', + raise_if_not_found=False + ) + if type_to_defer_id: + leave_activities = leave_activities.filtered(lambda act: act.activity_type_id.id == type_to_defer_id) + if leave_activities: + self.env['hr.leave'].browse(leave_activities.mapped('res_id')).write({'payslip_state': 'done'}) # done or normal??? to check + return super()._action_done(feedback=feedback, attachment_ids=attachment_ids) diff --git a/addons_extensions/hr_payroll_holidays/models/res_company.py b/addons_extensions/hr_payroll_holidays/models/res_company.py new file mode 100644 index 000000000..cb5c8c745 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/res_company.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + deferred_time_off_manager = fields.Many2one('res.users') diff --git a/addons_extensions/hr_payroll_holidays/models/res_config_settings.py b/addons_extensions/hr_payroll_holidays/models/res_config_settings.py new file mode 100644 index 000000000..3035f99fc --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + deferred_time_off_manager = fields.Many2one('res.users', related='company_id.deferred_time_off_manager', check_company=True, readonly=False) diff --git a/addons_extensions/hr_payroll_holidays/security/hr_payroll_holidays_security.xml b/addons_extensions/hr_payroll_holidays/security/hr_payroll_holidays_security.xml new file mode 100644 index 000000000..05087132e --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/security/hr_payroll_holidays_security.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_form.js b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_form.js new file mode 100644 index 000000000..c7bbbd76e --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_form.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { TimeOffToDeferWarning, useTimeOffToDefer } from "@hr_payroll_holidays/views/hooks"; +import { registry } from "@web/core/registry"; +import { FormController } from "@web/views/form/form_controller"; +import { formView } from "@web/views/form/form_view"; + +export class PayslipFormController extends FormController { + static template = "hr_payroll_holidays.PayslipFormController"; + static components = { ...PayslipFormController.components, TimeOffToDeferWarning }; + + setup() { + super.setup(); + this.timeOff = useTimeOffToDefer(); + } +} + +registry.category("views").add("hr_payslip_form", { + ...formView, + Controller: PayslipFormController, +}); diff --git a/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_form.xml b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_form.xml new file mode 100644 index 000000000..97ecd53bd --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_form.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_list.js b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_list.js new file mode 100644 index 000000000..6b6263cad --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_list.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ + +import { TimeOffToDeferWarning, useTimeOffToDefer } from "@hr_payroll_holidays/views/hooks"; +import { registry } from "@web/core/registry"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { listView } from "@web/views/list/list_view"; + +class PayslipListRenderer extends ListRenderer { + static template = "hr_payroll_holidays.PayslipListRenderer"; + static components = { ...ListRenderer.components, TimeOffToDeferWarning }; + + setup() { + super.setup(); + this.timeOff = useTimeOffToDefer(); + } +} +const PayslipListView = { + ...listView, + Renderer: PayslipListRenderer, +}; + +registry.category("views").add("hr_payroll_payslip_tree", PayslipListView); diff --git a/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_list.xml b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_list.xml new file mode 100644 index 000000000..fed0f958c --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/static/src/js/hr_payslip_list.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons_extensions/hr_payroll_holidays/static/src/views/hooks.js b/addons_extensions/hr_payroll_holidays/static/src/views/hooks.js new file mode 100644 index 000000000..0c6f563ef --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/static/src/views/hooks.js @@ -0,0 +1,40 @@ +import { Component, onWillStart } from "@odoo/owl"; + +import { _t } from "@web/core/l10n/translation"; +import { user } from "@web/core/user"; +import { useService } from "@web/core/utils/hooks"; + +export class TimeOffToDeferWarning extends Component { + static props = {}; + static template = "hr_payroll_holidays.TimeOffToDeferWarning"; + + setup() { + this.actionService = useService("action"); + } + + /** @returns {string} */ + get timeOffButtonText() { + const [, before, inside, after] = _t( + "You have some to defer to the next month." + ).match(/(.*) to defer to the next month. +

+ + + diff --git a/addons_extensions/hr_payroll_holidays/tests/__init__.py b/addons_extensions/hr_payroll_holidays/tests/__init__.py new file mode 100644 index 000000000..eb01201af --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_timeoff_defer diff --git a/addons_extensions/hr_payroll_holidays/tests/common.py b/addons_extensions/hr_payroll_holidays/tests/common.py new file mode 100644 index 000000000..d9c321a93 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/tests/common.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.fields import Date +from odoo.tests.common import TransactionCase, new_test_user + +from dateutil.relativedelta import relativedelta + + +class TestPayrollHolidaysBase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.company_id.resource_calendar_id.tz = "Europe/Brussels" + cls.env.context = {'tz': 'Europe/Brussels'} + cls.dep_rd = cls.env['hr.department'].create({ + 'name': 'Research & Development - Test', + }) + + # Create employee + cls.vlad = new_test_user(cls.env, login='vlad', groups='base.group_user') + cls.emp = cls.env['hr.employee'].create({ + 'name': 'Donald', + 'gender': 'male', + 'birthday': '1946-06-14', + 'department_id': cls.dep_rd.id, + 'user_id': cls.vlad.id, + }) + + cls.joseph = new_test_user(cls.env, login='joseph', groups='base.group_user,hr_holidays.group_hr_holidays_user') + + cls.structure_type = cls.env['hr.payroll.structure.type'].create({ + 'name': 'Test - Developer', + }) + + # Create his contract + cls.env['hr.contract'].create({ + 'date_end': Date.today() + relativedelta(years=2), + 'date_start': Date.to_date('2018-01-01'), + 'name': 'Contract for Donald', + 'wage': 5000.0, + 'employee_id': cls.emp.id, + 'structure_type_id': cls.structure_type.id, + 'state': 'open', + }) + + cls.work_entry_type_unpaid = cls.env['hr.work.entry.type'].create({ + 'name': 'Unpaid Leave', + 'is_leave': True, + 'code': 'LEAVETEST300', + 'round_days': 'HALF', + 'round_days_type': 'DOWN', + }) + + # Create a salary structure, necessary to compute sheet + cls.developer_pay_structure = cls.env['hr.payroll.structure'].create({ + 'name': 'Salary Structure for Software Developer', + 'type_id': cls.structure_type.id, + 'unpaid_work_entry_type_ids': [(4, cls.work_entry_type_unpaid.id, False)] + }) + cls.structure_type.default_struct_id = cls.developer_pay_structure + + # Create a leave type for our leaves + cls.leave_type = cls.env['hr.leave.type'].create({ + 'name': 'Unpaid leave', + 'work_entry_type_id': cls.work_entry_type_unpaid.id, + 'time_type': 'leave', + 'requires_allocation': 'no', + }) diff --git a/addons_extensions/hr_payroll_holidays/tests/test_timeoff_defer.py b/addons_extensions/hr_payroll_holidays/tests/test_timeoff_defer.py new file mode 100644 index 000000000..e99fd936d --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/tests/test_timeoff_defer.py @@ -0,0 +1,386 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, datetime + +from odoo.exceptions import ValidationError, UserError +from odoo.fields import Datetime +from odoo.tests.common import tagged +from odoo.addons.hr_payroll_holidays.tests.common import TestPayrollHolidaysBase + +from dateutil.relativedelta import relativedelta + + +@tagged('post_install', '-at_install') +class TestTimeoffDefer(TestPayrollHolidaysBase): + + def test_no_defer(self): + #create payslip -> waiting or draft + payslip = self.env['hr.payslip'].create({ + 'name': 'Donald Payslip', + 'employee_id': self.emp.id, + }) + + # Puts the payslip to draft/waiting + payslip.compute_sheet() + + #create a time off for our employee, validating it now should not put it as to_defer + leave = self.env['hr.leave'].create({ + 'name': 'Golf time', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': (date.today() + relativedelta(day=13)), + 'request_date_to': (date.today() + relativedelta(day=16)), + }) + leave.action_approve() + + self.assertNotEqual(leave.payslip_state, 'blocked', 'Leave should not be to defer') + + def test_to_defer(self): + #create payslip + payslip = self.env['hr.payslip'].create({ + 'name': 'Donald Payslip', + 'employee_id': self.emp.id, + }) + + # Puts the payslip to draft/waiting + payslip.compute_sheet() + payslip.action_payslip_done() + + #create a time off for our employee, validating it now should put it as to_defer + leave = self.env['hr.leave'].create({ + 'name': 'Golf time', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': (date.today() + relativedelta(day=13)), + 'request_date_to': (date.today() + relativedelta(day=16)), + }) + leave.action_approve() + self.assertEqual(leave.payslip_state, 'blocked', 'Leave should be to defer') + + def test_multi_payslip_defer(self): + #A leave should only be set to defer if ALL colliding with the time period of the time off are in a done state + # it should not happen if a payslip for that time period is still in a waiting state + + #create payslip -> waiting + waiting_payslip = self.env['hr.payslip'].create({ + 'name': 'Donald Payslip draft', + 'employee_id': self.emp.id, + }) + #payslip -> done + done_payslip = self.env['hr.payslip'].create({ + 'name': 'Donald Payslip done', + 'employee_id': self.emp.id, + }) + + # Puts the waiting payslip to draft/waiting + waiting_payslip.compute_sheet() + # Puts the done payslip to the done state + done_payslip.compute_sheet() + done_payslip.action_payslip_done() + + #create a time off for our employee, validating it now should not put it as to_defer + leave = self.env['hr.leave'].create({ + 'name': 'Golf time', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': (date.today() + relativedelta(day=13)), + 'request_date_to': (date.today() + relativedelta(day=16)), + }) + leave.action_approve() + + self.assertNotEqual(leave.payslip_state, 'blocked', 'Leave should not be to defer') + + def test_payslip_paid_past(self): + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2022-01-01', + 'date_to': '2022-01-31', + }) + + payslip.compute_sheet() + self.assertEqual(payslip.state, 'verify') + + leave_1 = self.env['hr.leave'].with_user(self.vlad).create({ + 'name': 'Tennis', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': '2022-01-12', + 'request_date_to': '2022-01-12', + }) + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave_1.sudo().action_validate() + self.assertEqual(leave_1.payslip_state, 'blocked', 'Leave should be to defer') + + # A Simple User can request a leave if a payslip is paid + leave_2 = self.env['hr.leave'].with_user(self.vlad).create({ + 'name': 'Tennis', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': '2022-01-19', + 'request_date_to': '2022-01-19', + }) + leave_2.sudo().action_validate() + self.assertEqual(leave_2.payslip_state, 'blocked', 'Leave should be to defer') + + # Check overlapping periods with no payslip + leave_3 = self.env['hr.leave'].with_user(self.vlad).create({ + 'name': 'Tennis', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': '2022-01-31', + 'request_date_to': '2022-02-01', + }) + leave_3.sudo().action_validate() + self.assertEqual(leave_3.payslip_state, 'blocked', 'Leave should be to defer') + + leave_4 = self.env['hr.leave'].with_user(self.vlad).create({ + 'name': 'Tennis', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': '2021-01-31', + 'request_date_to': '2022-01-03', + }) + leave_4.sudo().action_validate() + self.assertEqual(leave_4.payslip_state, 'blocked', 'Leave should be to defer') + + def test_report_to_next_month(self): + self.emp.contract_ids.generate_work_entries(date(2022, 1, 1), date(2022, 2, 28)) + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2022-01-01', + 'date_to': '2022-01-31', + }) + payslip.compute_sheet() + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave = self.env['hr.leave'].new({ + 'name': 'Tennis', + 'employee_id': self.emp.id, + 'holiday_status_id': self.leave_type.id, + 'request_date_from': date(2022, 1, 31), + 'request_date_to': date(2022, 1, 31), + 'request_hour_from': 7, + 'request_hour_to': 18, + }) + leave._compute_date_from_to() + leave = self.env['hr.leave'].create(leave._convert_to_write(leave._cache)) + leave.action_validate() + self.assertEqual(leave.payslip_state, 'blocked', 'Leave should be to defer') + + leave.action_report_to_next_month() + reported_work_entries = self.env['hr.work.entry'].search([ + ('employee_id', '=', self.emp.id), + ('company_id', '=', self.env.company.id), + ('state', '=', 'draft'), + ('work_entry_type_id', '=', self.leave_type.work_entry_type_id.id), + ('date_start', '>=', Datetime.to_datetime('2022-02-01')), + ('date_stop', '<=', datetime.combine(Datetime.to_datetime('2022-02-28'), datetime.max.time())) + ]) + self.assertEqual(reported_work_entries[0].date_start, datetime(2022, 2, 1, 7, 0)) + self.assertEqual(reported_work_entries[0].date_stop, datetime(2022, 2, 1, 11, 0)) + self.assertEqual(reported_work_entries[1].date_start, datetime(2022, 2, 1, 12, 0)) + self.assertEqual(reported_work_entries[1].date_stop, datetime(2022, 2, 1, 16, 0)) + + def test_report_to_next_month_overlap(self): + """ + If the time off overlap over 2 months, only report the exceeding part from january + In case leaves go over two months, only the leaves that are in the first month should be defered + """ + self.emp.contract_ids.generate_work_entries(date(2022, 1, 1), date(2022, 2, 28)) + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2022-01-01', + 'date_to': '2022-01-31', + }) + payslip.compute_sheet() + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave = self.env['hr.leave'].new({ + 'name': 'Tennis', + 'employee_id': self.emp.id, + 'holiday_status_id': self.leave_type.id, + 'request_date_from': date(2022, 1, 31), + 'request_date_to': date(2022, 2, 2), + 'request_hour_from': 7, + 'request_hour_to': 18, + }) + leave._compute_date_from_to() + leave = self.env['hr.leave'].create(leave._convert_to_write(leave._cache)) + leave.action_validate() + self.assertEqual(leave.payslip_state, 'blocked', 'Leave should be to defer') + + leave.action_report_to_next_month() + reported_work_entries = self.env['hr.work.entry'].search([ + ('employee_id', '=', self.emp.id), + ('company_id', '=', self.env.company.id), + ('state', '=', 'draft'), + ('work_entry_type_id', '=', self.leave_type.work_entry_type_id.id), + ('date_start', '>=', Datetime.to_datetime('2022-02-01')), + ('date_stop', '<=', datetime.combine(Datetime.to_datetime('2022-02-28'), datetime.max.time())) + ]) + self.assertEqual(len(reported_work_entries), 6) + self.assertEqual(list({we.date_start.day for we in reported_work_entries}), [1, 2, 3]) + self.assertEqual(reported_work_entries[0].date_start, datetime(2022, 2, 1, 7, 0)) + self.assertEqual(reported_work_entries[0].date_stop, datetime(2022, 2, 1, 11, 0)) + self.assertEqual(reported_work_entries[1].date_start, datetime(2022, 2, 1, 12, 0)) + self.assertEqual(reported_work_entries[1].date_stop, datetime(2022, 2, 1, 16, 0)) + + def test_report_to_next_month_not_enough_days(self): + # If the time off contains too many days to be reported to next months, raise + self.emp.contract_ids.generate_work_entries(date(2022, 1, 1), date(2022, 2, 28)) + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2022-01-01', + 'date_to': '2022-01-31', + }) + payslip.compute_sheet() + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave = self.env['hr.leave'].new({ + 'name': 'Tennis', + 'employee_id': self.emp.id, + 'holiday_status_id': self.leave_type.id, + 'request_date_from': date(2022, 1, 1), + 'request_date_to': date(2022, 1, 31), + 'request_hour_from': 7, + 'request_hour_to': 18, + }) + leave._compute_date_from_to() + leave = self.env['hr.leave'].create(leave._convert_to_write(leave._cache)) + leave.action_validate() + self.assertEqual(leave.payslip_state, 'blocked', 'Leave should be to defer') + + with self.assertRaises(UserError): + leave.action_report_to_next_month() + + def test_report_to_next_month_long_time_off(self): + # If the time off overlap over more than 2 months, raise + self.emp.contract_ids.generate_work_entries(date(2022, 1, 1), date(2022, 2, 28)) + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2022-01-01', + 'date_to': '2022-01-31', + }) + payslip.compute_sheet() + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave = self.env['hr.leave'].new({ + 'name': 'Tennis', + 'employee_id': self.emp.id, + 'holiday_status_id': self.leave_type.id, + 'request_date_from': date(2022, 1, 1), + 'request_date_to': date(2022, 3, 10), + 'request_hour_from': 7, + 'request_hour_to': 18, + }) + leave._compute_date_from_to() + leave = self.env['hr.leave'].create(leave._convert_to_write(leave._cache)) + leave.action_validate() + self.assertEqual(leave.payslip_state, 'blocked', 'Leave should be to defer') + + with self.assertRaises(UserError): + leave.action_report_to_next_month() + + def test_report_to_next_month_half_days(self): + self.leave_type.request_unit = 'half_day' + self.emp.contract_ids.generate_work_entries(date(2022, 1, 1), date(2022, 2, 28)) + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2022-01-01', + 'date_to': '2022-01-31', + }) + payslip.compute_sheet() + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave = self.env['hr.leave'].new({ + 'name': 'Tennis', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.emp.id, + 'request_date_from': date(2022, 1, 31), + 'request_date_to': date(2022, 1, 31), + 'request_unit_half': True, + 'request_date_from_period': 'am', + }) + leave._compute_date_from_to() + leave = self.env['hr.leave'].create(leave._convert_to_write(leave._cache)) + + leave.action_validate() + self.assertEqual(leave.payslip_state, 'blocked', 'Leave should be to defer') + + leave.action_report_to_next_month() + reported_work_entries = self.env['hr.work.entry'].search([ + ('employee_id', '=', self.emp.id), + ('company_id', '=', self.env.company.id), + ('state', '=', 'draft'), + ('work_entry_type_id', '=', self.leave_type.work_entry_type_id.id), + ('date_start', '>=', Datetime.to_datetime('2022-02-01')), + ('date_stop', '<=', datetime.combine(Datetime.to_datetime('2022-02-28'), datetime.max.time())) + ]) + self.assertEqual(len(reported_work_entries), 1) + self.assertEqual(reported_work_entries[0].date_start, datetime(2022, 2, 1, 7, 0)) + self.assertEqual(reported_work_entries[0].date_stop, datetime(2022, 2, 1, 11, 0)) + + def test_defer_next_month_double_time_off(self): + """ + If you have a time off 5 days on Jun and 3 days on july, when you "defer it to next month" + it's only the 5 days of Jun that should be postponed to july. + """ + self.emp.contract_ids._generate_work_entries(datetime(2023, 6, 1), datetime(2023, 7, 31)) + payslip = self.env['hr.payslip'].create({ + 'name': 'toto payslip', + 'employee_id': self.emp.id, + 'date_from': '2023-06-01', + 'date_to': '2023-06-30', + }) + payslip.compute_sheet() + payslip.action_payslip_done() + self.assertEqual(payslip.state, 'done') + + leave_data = [{ + 'name': 'Paid Time Off', + 'employee_id': self.emp.id, + 'holiday_status_id': self.leave_type.id, + 'request_date_from': '2023-06-26 00:00:00', + 'request_date_to': '2023-06-30 23:59:59', + }, { + 'name': 'Paid Time Off', + 'employee_id': self.emp.id, + 'holiday_status_id': self.leave_type.id, + 'request_date_from': '2023-07-03 00:00:00', + 'request_date_to': '2023-07-05 23:59:59', + }] + leaves = self.env['hr.leave'].create(leave_data) + leaves.action_validate() + leaves[0].action_report_to_next_month() + # reported work entries between the 1st of july 2023 to the 31st of july 2023 + july_work_entries = self.env['hr.work.entry'].search([ + ('employee_id', '=', self.emp.id), + ('company_id', '=', self.env.company.id), + ('state', '=', 'draft'), + ('work_entry_type_id', '=', self.leave_type.work_entry_type_id.id), + ('date_start', '>=', Datetime.to_datetime('2023-07-01')), + ('date_stop', '<=', datetime.combine(Datetime.to_datetime('2023-07-31'), datetime.max.time())) + ]) + # The length of reported work entries is 16 because we are generating records for 8 days of leave. + # Each day is divided into two parts, morning and afternoon, resulting in a total of 16 work entries. + # These leaves cover the period from July 3rd to July 12th, excluding July 1st and 2nd as they are designated holidays. + self.assertEqual(len(july_work_entries), 16) + self.assertEqual(list({we.date_start.day for we in july_work_entries}), [3, 4, 5, 6, 7, 10, 11, 12]) + self.assertEqual(july_work_entries[0].date_start, datetime(2023, 7, 3, 6, 0)) + self.assertEqual(july_work_entries[0].date_stop, datetime(2023, 7, 3, 10, 0)) + self.assertEqual(july_work_entries[1].date_start, datetime(2023, 7, 3, 11, 0)) + self.assertEqual(july_work_entries[1].date_stop, datetime(2023, 7, 3, 15, 0)) diff --git a/addons_extensions/hr_payroll_holidays/views/hr_allocation_views.xml b/addons_extensions/hr_payroll_holidays/views/hr_allocation_views.xml new file mode 100644 index 000000000..a0341fd69 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/views/hr_allocation_views.xml @@ -0,0 +1,13 @@ + + + + hr.view.leave.allocation.inherit.filter + hr.leave.allocation + + + + + + + + diff --git a/addons_extensions/hr_payroll_holidays/views/hr_leave_views.xml b/addons_extensions/hr_payroll_holidays/views/hr_leave_views.xml new file mode 100644 index 000000000..6a8cdc759 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/views/hr_leave_views.xml @@ -0,0 +1,87 @@ + + + + hr.leave.view.form.inherit.hr.payroll.holidays + hr.leave + + + + + + + + + + + + + + + Time Off to Defer + hr.leave + list,kanban,form,calendar,activity + + [('payslip_state', '=', 'blocked'), ('state', '=', 'validate'), ('employee_company_id', 'in', allowed_company_ids)] + + + + hr.leave.view.form.inherit + hr.leave + + + + + + + + + + + hr.holidays.view.list.inherit.work.entry + hr.leave + + primary + + + + + + + + + Time Off + hr.leave + list,kanban,form,calendar,activity + + + { + 'search_default_to_defer':1, + 'hide_employee_name': 1 + } + +

+ Meet the time off dashboard. +

+ A great way to keep track on employee’s PTOs, sick days, and approval status. +

+
+
+ + + +
diff --git a/addons_extensions/hr_payroll_holidays/views/hr_payslip_run_views.xml b/addons_extensions/hr_payroll_holidays/views/hr_payslip_run_views.xml new file mode 100644 index 000000000..403df409a --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/views/hr_payslip_run_views.xml @@ -0,0 +1,13 @@ + + + + hr.payslip.run.view.list.inherit.hr.payroll.holidays + hr.payslip.run + + + + hr_payroll_payslip_tree + + + + diff --git a/addons_extensions/hr_payroll_holidays/views/hr_payslip_views.xml b/addons_extensions/hr_payroll_holidays/views/hr_payslip_views.xml new file mode 100644 index 000000000..f6385fa51 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/views/hr_payslip_views.xml @@ -0,0 +1,24 @@ + + + + hr.payslip.view.form.inherit.hr.payroll.holidays + hr.payslip + + + + hr_payslip_form + + + + + + hr.payslip.view.list.inherit.hr.payroll.holidays + hr.payslip + + + + hr_payroll_payslip_tree + + + + diff --git a/addons_extensions/hr_payroll_holidays/views/res_config_settings_views.xml b/addons_extensions/hr_payroll_holidays/views/res_config_settings_views.xml new file mode 100644 index 000000000..824690616 --- /dev/null +++ b/addons_extensions/hr_payroll_holidays/views/res_config_settings_views.xml @@ -0,0 +1,24 @@ + + + + res.config.settings.view.form.inherit.hr.payroll.holidays + res.config.settings + + + + + + +
+
+
+
+
+
+
+
+
+