payroll management timeoff

This commit is contained in:
Pranay 2025-04-16 12:47:08 +05:30 committed by raman
parent 08f388019f
commit c9555b962e
32 changed files with 1184 additions and 1 deletions

View File

@ -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()

View File

@ -0,0 +1,4 @@
#-*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

View File

@ -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',
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_holidays.hr_holidays_cl_mit_2" model="hr.leave">
<field name="payslip_state">blocked</field>
</record>
<record id="hr_holidays.hr_holidays_sl_qdp" model="hr.leave">
<field name="payslip_state">blocked</field>
</record>
</odoo>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="hr_payroll_dashboard_warning_leaves_to_defer" model="hr.payroll.dashboard.warning">
<field name="name">Time Off To Defer</field>
<field name="country_id" eval="False"/>
<field name="evaluation_code">
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'
</field>
</record>
<record id="hr_payroll_dashboard_warning_leaves_no_document" model="hr.payroll.dashboard.warning">
<field name="name">Time Off Without Joined Document</field>
<field name="country_id" eval="False"/>
<field name="evaluation_code">
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
</field>
</record>
<record id="hr_payroll_dashboard_warning_leaves_no_allocation" model="hr.payroll.dashboard.warning">
<field name="name">Time Off Not Related To An Allocation</field>
<field name="country_id" eval="False"/>
<field name="evaluation_code">
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)])
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_actions_server_report_to_next_month" model="ir.actions.server">
<field name="name">Defer to Next Month</field>
<field name="model_id" ref="hr_holidays.model_hr_leave"/>
<field name="binding_model_id" ref="hr_holidays.model_hr_leave"/>
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('hr_payroll.group_hr_payroll_user'))]"/>
<field name="state">code</field>
<field name="code">
records.action_report_to_next_month()
</field>
</record>
<record id="ir_actions_server_mark_as_reported" model="ir.actions.server">
<field name="name">Mark as deferred</field>
<field name="model_id" ref="hr_holidays.model_hr_leave"/>
<field name="binding_model_id" ref="hr_holidays.model_hr_leave"/>
<field name="binding_view_types">list,form</field>
<field name="groups_id" eval="[(4, ref('hr_payroll.group_hr_payroll_user'))]"/>
<field name="state">code</field>
<field name="code">
records.activity_feedback(['hr_payroll_holidays.mail_activity_data_hr_leave_to_defer'])
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_activity_data_hr_leave_to_defer" model="mail.activity.type">
<field name="name">Leave to Defer</field>
<field name="icon">fa-plane</field>
<field name="sequence">100</field>
<field name="res_model">hr.leave</field>
</record>
</data>
</odoo>

View File

@ -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

View File

@ -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')

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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')

View File

@ -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)

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="hr_payroll.group_hr_payroll_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('hr_holidays.group_hr_holidays_user'))]"/>
</record>
<record id="hr_payroll.group_hr_payroll_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('hr_holidays.group_hr_holidays_user'))]"/>
</record>
</data>
</odoo>

View File

@ -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,
});

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="hr_payroll_holidays.PayslipFormController" t-inherit="web.FormView" t-inherit-mode="primary">
<xpath expr="//Layout/t[@t-component='props.Renderer']" position="before">
<TimeOffToDeferWarning t-if="timeOff.hasTimeOffToDefer"/>
</xpath>
</t>
</templates>

View File

@ -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);

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="hr_payroll_holidays.PayslipListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_list_renderer')]" position="before">
<TimeOffToDeferWarning t-if="timeOff.hasTimeOffToDefer"/>
</xpath>
</t>
</templates>

View File

@ -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 <button>time off</button> to defer to the next month."
).match(/(.*)<button>(.*)<\/button>(.*)/) ?? [
"You have some",
"time off",
"to defer to the next month.",
];
return { before, inside, after };
}
onTimeOffToDefer() {
this.actionService.doAction("hr_payroll_holidays.hr_leave_action_open_to_defer");
}
}
export function useTimeOffToDefer() {
const orm = useService("orm");
const timeOff = {};
onWillStart(async () => {
const result = await orm.searchCount('hr.leave', [["payslip_state", "=", "blocked"], ["state", "=", "validate"], ["employee_company_id", "in", user.context.allowed_company_ids]]);
timeOff.hasTimeOffToDefer = result > 0;
});
return timeOff;
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<templates>
<t t-name="hr_payroll_holidays.TimeOffToDeferWarning">
<div class="alert alert-warning text-center mb-0" role="alert">
<p class="mb-0">
<t t-esc="timeOffButtonText.before"/>
<button class="btn btn-link p-0 align-baseline o_open_defer_time_off" role="button" t-esc="timeOffButtonText.inside" t-on-click="onTimeOffToDefer"/>
<t t-esc="timeOffButtonText.after"/>
</p>
</div>
</t>
</templates>

View File

@ -0,0 +1,19 @@
/** @odoo-module **/
import { TimeOffToDeferWarning, useTimeOffToDefer } from "@hr_payroll_holidays/views/hooks";
import { WorkEntryCalendarController } from "@hr_work_entry_contract/views/work_entry_calendar/work_entry_calendar_controller";
import { patch } from "@web/core/utils/patch";
patch(
WorkEntryCalendarController.prototype,
{
setup() {
super.setup(...arguments);
this.timeOff = useTimeOffToDefer();
},
}
);
patch(WorkEntryCalendarController, {
template: "hr_payroll_holidays.WorkEntryCalendarController",
components: { ...WorkEntryCalendarController.components, TimeOffToDeferWarning },
});

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="hr_payroll_holidays.WorkEntryCalendarController" t-inherit="web.CalendarController" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_calendar_container')]" position="before">
<TimeOffToDeferWarning t-if="timeOff.hasTimeOffToDefer"/>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="warning.time.off.to.defer">
<div class="alert alert-warning text-center mb-0" role="alert">
<p class="mb-0">
You have some <button class="btn btn-link p-0 o_open_defer_time_off" role="button">time off</button> to defer to the next month.
</p>
</div>
</t>
</templates>

View File

@ -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

View File

@ -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',
})

View File

@ -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))

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_hr_leave_allocation_inherit_filter" model="ir.ui.view">
<field name="name">hr.view.leave.allocation.inherit.filter</field>
<field name="model">hr.leave.allocation</field>
<field name="inherit_id" ref="hr_holidays.view_hr_leave_allocation_filter"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='employee_id']" position="after">
<field name="employee_id" string="Employee Code" filter_domain="[('employee_id.registration_number','ilike', self)]"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_leave_view_search" model="ir.ui.view">
<field name="name">hr.leave.view.form.inherit.hr.payroll.holidays</field>
<field name="model">hr.leave</field>
<field name="inherit_id" ref="hr_holidays.hr_leave_view_search_manager"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='group_employee']" position="before">
<separator/>
<filter string="To Defer" name="to_defer" domain="[('payslip_state', '=', 'blocked')]" groups="hr_holidays.group_hr_holidays_user"/>
<separator/>
</xpath>
<xpath expr="//field[@name='employee_id']" position="after">
<field name="employee_id" string="Employee Code" filter_domain="[('employee_id.registration_number','ilike', self)]"/>
</xpath>
</field>
</record>
<record id="hr_leave_action_open_to_defer" model="ir.actions.act_window">
<field name="name">Time Off to Defer</field>
<field name="res_model">hr.leave</field>
<field name="view_mode">list,kanban,form,calendar,activity</field>
<field name="search_view_id" ref="hr_holidays.hr_leave_view_search_manager"/>
<field name="domain">[('payslip_state', '=', 'blocked'), ('state', '=', 'validate'), ('employee_company_id', 'in', allowed_company_ids)]</field>
</record>
<record id="hr_leave_view_form_inherit" model="ir.ui.view">
<field name="name">hr.leave.view.form.inherit</field>
<field name="model">hr.leave</field>
<field name="inherit_id" ref="hr_holidays.hr_leave_view_form_manager"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='holiday_status_id']" position="after">
<field name="payslip_state" widget="state_selection"
options="{'hide_label': False}" class="ms-auto mb-2"/>
</xpath>
<button name="action_refuse" position="before">
<button
string="Report to Next Month"
name="action_report_to_next_month"
type="object"
class="oe_highlight"
groups="hr_payroll.group_hr_payroll_user"
invisible="payslip_state != 'blocked' or state != 'validate'"/>
</button>
</field>
</record>
<record id="hr_leave_view_tree_inherit_payroll" model="ir.ui.view">
<field name="name">hr.holidays.view.list.inherit.work.entry</field>
<field name="model">hr.leave</field>
<field name="inherit_id" ref="hr_holidays.hr_leave_view_tree"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='state']" position="after">
<field name="payslip_state" widget="state_selection" options="{'hide_label': False}"/>
</xpath>
</field>
</record>
<record id="hr_leave_work_entry_action" model="ir.actions.act_window">
<field name="name">Time Off</field>
<field name="res_model">hr.leave</field>
<field name="view_mode">list,kanban,form,calendar,activity</field>
<field name="search_view_id" ref="hr_holidays.hr_leave_view_search_manager"/>
<field name="view_id" ref="hr_payroll_holidays.hr_leave_view_tree_inherit_payroll"/>
<field name="context">{
'search_default_to_defer':1,
'hide_employee_name': 1
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Meet the time off dashboard.
</p><p>
A great way to keep track on employees PTOs, sick days, and approval status.
</p>
</field>
</record>
<menuitem
id="menu_work_entry_leave_to_approve"
name="Time Off to Report"
action="hr_leave_work_entry_action"
parent="hr_payroll.menu_hr_payroll_work_entries_root"
sequence="75"
groups="hr_holidays.group_hr_holidays_user"/>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_payslip_run_view_tree" model="ir.ui.view">
<field name="name">hr.payslip.run.view.list.inherit.hr.payroll.holidays</field>
<field name="model">hr.payslip.run</field>
<field name="inherit_id" ref="hr_payroll.hr_payslip_run_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="js_class">hr_payroll_payslip_tree</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_payslip_view_form" model="ir.ui.view">
<field name="name">hr.payslip.view.form.inherit.hr.payroll.holidays</field>
<field name="model">hr.payslip</field>
<field name="inherit_id" ref="hr_payroll.view_hr_payslip_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="attributes">
<attribute name="js_class">hr_payslip_form</attribute>
</xpath>
</field>
</record>
<record id="hr_payslip_view_tree" model="ir.ui.view">
<field name="name">hr.payslip.view.list.inherit.hr.payroll.holidays</field>
<field name="model">hr.payslip</field>
<field name="inherit_id" ref="hr_payroll.view_hr_payslip_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="js_class">hr_payroll_payslip_tree</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.hr.payroll.holidays</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="45"/>
<field name="inherit_id" ref="hr_payroll.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='hr_payroll_accountant']" position="after">
<block title="Time Off">
<setting string="Deferred Time Off" help="Postpone time off after payslip validation">
<div class="content-group">
<div class="row mt16 ms-2">
<label string="Responsible" for="deferred_time_off_manager"
class="col-md-6 p-0 m-0 o_light_label"/>
<field name="deferred_time_off_manager" class="col-lg-6 p-0"/>
</div>
</div>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>