Files
hr-tools/l10n_fr_hr_holidays/models/hr_leave.py

250 lines
13 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import fields, models, _, api
from odoo.exceptions import UserError
class HrLeave(models.Model):
_inherit = 'hr.leave'
resource_calendar_id = fields.Many2one('resource.calendar', compute='_compute_resource_calendar_id', store=True, readonly=False, copy=False)
l10n_fr_date_to_changed = fields.Boolean()
@api.depends(
'holiday_type',
'employee_id',
'employee_id.resource_calendar_id',
'request_date_from',
'request_date_to',
)
def _compute_resource_calendar_id(self):
for leave in self:
calendar = False
if leave.holiday_type == 'employee':
calendar = leave.employee_id.resource_calendar_id
# YTI: Crappy hack: Move this to a new dedicated hr_holidays_contract module
# We use the request dates to find the contracts, because date_from
# and date_to are not set yet at this point. Since these dates are
# used to get the contracts for which these leaves apply and
# contract start- and end-dates are just dates (and not datetimes)
# these dates are comparable.
if 'hr.contract' in self.env and leave.employee_id:
contracts = self.env['hr.contract'].search([
'|', ('state', 'in', ['open', 'close']),
'&', ('state', '=', 'draft'),
('kanban_state', '=', 'done'),
('employee_id', '=', leave.employee_id.id),
('date_start', '<=', leave.request_date_to),
'|', ('date_end', '=', False),
('date_end', '>=', leave.request_date_from),
])
if contracts:
# If there are more than one contract they should all have the
# same calendar, otherwise a constraint is violated.
calendar = contracts[:1].resource_calendar_id
# Crappy hack again : if hr_employee_calendar_planning is installed
# use planning calendar instead of contract calendar
if 'hr.employee.calendar' in self.env:
calendar_planning = self.env["hr.employee.calendar"].search(
[
("employee_id", "=", leave.employee_id.id),
("date_start", "<=", leave.request_date_to),
"|", ("date_end", "=", False), # pas de date de fin OU
("date_end", ">=", leave.request_date_from), # date de fin après le début
],
limit=1,
)
if calendar_planning:
calendar = calendar_planning.calendar_id
elif leave.holiday_type == 'department':
calendar = leave.department_id.company_id.resource_calendar_id
elif leave.holiday_type == 'company':
calendar = leave.mode_company_id.resource_calendar_id
leave.resource_calendar_id = calendar or self.env.company.resource_calendar_id
def _l10n_fr_leave_applies(self):
# The french l10n is meant to be computed only in very specific cases:
# - there is only one employee affected by the leave
# - the company is french
# - the leave_type is the reference leave_type of that company
self.ensure_one()
return self.employee_id and \
self.employee_company_id.country_id.code == 'FR' and \
self.resource_calendar_id != self.employee_company_id.resource_calendar_id and \
self.holiday_status_id == self.employee_company_id._get_fr_reference_leave_type()
def _get_fr_date_from_to(self, date_from, date_to):
self.ensure_one()
# What we need to compute is how much we will need to push date_to in order to account for the lost days
# This gets even more complicated in two_weeks_calendars
# The following computation doesn't work for resource calendars in
# which the employee works zero hours.
if not (self.resource_calendar_id.attendance_ids):
raise UserError(_("An employee cannot take a paid time off in a period they work no hours."))
if self.request_unit_half and self.request_date_from_period == 'am':
# In normal workflows request_unit_half implies that date_from and date_to are the same
# request_unit_half allows us to choose between `am` and `pm`
# In a case where we work from mon-wed and request a half day in the morning
# we do not want to push date_to since the next work attendance is actually in the afternoon
date_from_weektype = str(self.env['resource.calendar.attendance'].get_week_type(date_from))
date_from_dayofweek = str(date_from.weekday())
# Get morning and afternoon attendances for that day
attendances_am = self.resource_calendar_id.attendance_ids.filtered(lambda a:
a.dayofweek == date_from_dayofweek
and a.day_period == 'morning'
and (not self.resource_calendar_id.two_weeks_calendar or a.week_type == date_from_weektype))
attendances_pm = self.resource_calendar_id.attendance_ids.filtered(lambda a:
a.dayofweek == date_from_dayofweek
and a.day_period == 'afternoon'
and (not self.resource_calendar_id.two_weeks_calendar or a.week_type == date_from_weektype))
if attendances_am and not attendances_pm:
# If the employee does not work in the afternoon, postpone date_to to the next working day
next_date = date_from + relativedelta(days=1)
while not self.resource_calendar_id._works_on_date(next_date):
next_date += relativedelta(days=1)
return (date_from, next_date)
elif attendances_am and attendances_pm:
# The employee also works in the afternoon, no postponement
return (date_from, date_to)
# Special handling for two-weeks calendars
if self.resource_calendar_id.two_weeks_calendar:
# Count the number of days actually worked by the employee between date_from and date_to
current_date = date_from
days_count = 0
while current_date <= date_to:
if self.resource_calendar_id._works_on_date(current_date):
days_count += 1
current_date += relativedelta(days=1)
# Adjust date_to so it matches the expected number of days
# If the expected number of days is less than the period, reduce date_to
if days_count > 0:
# Find the date_to that gives the right number of worked days
current_date = date_from
counted = 0
while counted < days_count:
if self.resource_calendar_id._works_on_date(current_date):
counted += 1
if counted == days_count:
break
current_date += relativedelta(days=1)
return (date_from, current_date)
# Check calendars for working days until we find the right target, start at date_to + 1 day
# Postpone date_target until the next working day
date_start = date_from
date_target = date_to
# It is necessary to move the start date up to the first work day of
# the employee calendar as otherwise days worked on by the company
# calendar before the actual start of the leave would be taken into
# account.
while not self.resource_calendar_id._works_on_date(date_start):
date_start += relativedelta(days=1)
while not self.resource_calendar_id._works_on_date(date_target + relativedelta(days=1)):
date_target += relativedelta(days=1)
# Undo the last day increment
return (date_start, date_target)
def _compute_date_from_to(self):
super()._compute_date_from_to()
for leave in self:
if leave._l10n_fr_leave_applies():
new_date_from, new_date_to = leave._get_fr_date_from_to(leave.date_from, leave.date_to)
if new_date_from != leave.date_from:
leave.date_from = new_date_from
if new_date_to != leave.date_to:
leave.date_to = new_date_to
leave.l10n_fr_date_to_changed = True
else:
leave.l10n_fr_date_to_changed = False
#@overwrite
def _get_calendar(self):
"""
In France, paid time off for part-time employees is counted on the company's working days (not the employee's own schedule).
The company's calendar must be used for the legal leave day count.
"""
self.ensure_one()
if self._l10n_fr_leave_applies():
return self.employee_company_id.resource_calendar_id or self.env.company.resource_calendar_id
return super()._get_calendar()
#@overwrite
def _get_number_of_days_batch(self, date_from, date_to, employee_ids):
"""
Returns a dict with the number of legal leave days for each employee.
In France, part-time employees' leave is counted in company working days
(jours ouvrables) from the first day of absence to the return day
(exclusive). The absence period is determined by the employee's calendar,
but the day count uses the company's calendar. Public holidays and other
global leaves registered on the company calendar are excluded.
For half-day requests, the first day counts as 0.5 if the employee
works the other half of that day (returns same day), or 1 otherwise.
"""
employee = self.env['hr.employee'].browse(employee_ids)
# Force the company in the domain, as we are likely in a compute_sudo context
domain = [
('time_type', '=', 'leave'),
('company_id', 'in', self.env.company.ids + self.env.context.get('allowed_company_ids', []))
]
calendar = self._get_calendar()
result = employee._get_work_days_data_batch(date_from, date_to, calendar=calendar, domain=domain)
for employee_id in result:
if self._l10n_fr_leave_applies():
company_calendar = calendar
employee_calendar = self.resource_calendar_id
first_day = date_from.date()
last_date = date_to.date()
# _get_fr_date_from_to returns date_to with different semantics:
# - AM half-day, employee doesn't work PM: date_to = return day
# (must be excluded from the count)
# - AM half-day, employee works PM: date_to = same day (leave day)
# - General case (full-day, PM half-day): date_to = last leave day
# (day before the return, must be included)
# Only the first case has date_to as the return day. It is
# detected by: AM half-day, date_to strictly after date_from.
if (self.request_unit_half and self.request_date_from_period == 'am'
and last_date > first_day):
last_date -= relativedelta(days=1)
# Get the set of dates where the company actually works between
# first_day and last_date, excluding public holidays (global leaves).
# Don't pass the company_id-filtered domain here: the leave is
# already matched by calendar_id in _leave_intervals_batch, and
# the company_id filter would exclude holidays created on the
# french company when self.env.company is the main company.
working_dates = company_calendar._get_working_dates(first_day, last_date)
if self.request_unit_half:
# Check if the employee works the other half of the first day
dayofweek = str(first_day.weekday())
other_period = 'afternoon' if self.request_date_from_period == 'am' else 'morning'
other_half = employee_calendar.attendance_ids.filtered(
lambda a: a.dayofweek == dayofweek and a.day_period == other_period
)
first_day_count = 0.5 if (other_half and first_day in working_dates) else (1.0 if first_day in working_dates else 0.0)
days_count = first_day_count
current_date = first_day + relativedelta(days=1)
else:
days_count = 0
current_date = first_day
while current_date <= last_date:
if current_date in working_dates:
days_count += 1
current_date += relativedelta(days=1)
result[employee_id]['days'] = float(days_count)
elif self.request_unit_half and result[employee_id]['hours'] > 0:
# Non-French context: a half-day leave always counts as 0.5 day
result[employee_id]['days'] = 0.5
return result
def _round_to_nearest_half(self, x):
"""Round a float to the nearest 0.5."""
return round(x * 2) / 2