19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:56 +01:00
parent a2f74aefd8
commit 4a4d12c333
844 changed files with 212348 additions and 270090 deletions

View file

@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_company # has to be before hr_holidays to create needed columns on res.company
from . import account_analytic
from . import hr_holidays
from . import hr_leave
from . import project_task
from . import res_config_settings
from . import resource_calendar_leaves

View file

@ -1,43 +1,57 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.exceptions import RedirectWarning, UserError
from odoo.fields import Domain
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
holiday_id = fields.Many2one("hr.leave", string='Leave Request', copy=False)
global_leave_id = fields.Many2one("resource.calendar.leaves", string="Global Time Off", ondelete='cascade')
task_id = fields.Many2one(domain="[('allow_timesheets', '=', True),"
"('project_id', '=?', project_id), ('is_timeoff_task', '=', False)]")
holiday_id = fields.Many2one("hr.leave", string='Time Off Request', copy=False, index='btree_not_null', export_string_translation=False)
global_leave_id = fields.Many2one("resource.calendar.leaves", string="Global Time Off", index='btree_not_null', ondelete='cascade', export_string_translation=False)
task_id = fields.Many2one(domain="[('allow_timesheets', '=', True), ('project_id', '=?', project_id), ('has_template_ancestor', '=', False), ('is_timeoff_task', '=', False)]")
_timeoff_timesheet_idx = models.Index('(task_id) WHERE (global_leave_id IS NOT NULL OR holiday_id IS NOT NULL) AND project_id IS NOT NULL')
def _get_redirect_action(self):
leave_form_view_id = self.env.ref('hr_holidays.hr_leave_view_form').id
action_data = {
'name': _('Time Off'),
'type': 'ir.actions.act_window',
'res_model': 'hr.leave',
'views': [(self.env.ref('hr_holidays.hr_leave_view_tree_my').id, 'list'), (leave_form_view_id, 'form')],
'domain': [('id', 'in', self.holiday_id.ids)],
}
if len(self.holiday_id) == 1:
action_data['views'] = [(leave_form_view_id, 'form')]
action_data['res_id'] = self.holiday_id.id
return action_data
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_leave(self):
if any(line.holiday_id for line in self):
raise UserError(_('You cannot delete timesheets that are linked to time off requests. Please cancel your time off request from the Time Off application instead.'))
if any(line.global_leave_id for line in self):
raise UserError(_('You cannot delete timesheets that are linked to global time off.'))
elif any(line.holiday_id for line in self):
error_message = _('You cannot delete timesheets that are linked to time off requests. Please cancel your time off request from the Time Off application instead.')
if not self.env.user.has_group('hr_holidays.group_hr_holidays_user') and self.env.user not in self.holiday_id.sudo().user_id:
raise UserError(error_message)
action = self._get_redirect_action()
raise RedirectWarning(error_message, action, _('View Time Off'))
@api.model_create_multi
def create(self, vals_list):
if not self.env.su:
task_ids = [vals['task_id'] for vals in vals_list if vals.get('task_id')]
has_timeoff_task = self.env['project.task'].search_count([('id', 'in', task_ids), ('is_timeoff_task', '=', True)], limit=1) > 0
if has_timeoff_task:
raise UserError(_('You cannot create timesheets for a task that is linked to a time off type. Please use the Time Off application to request new time off instead.'))
return super().create(vals_list)
def _check_can_update_timesheet(self):
return self.env.su or not self.filtered('holiday_id')
def write(self, vals):
if not self._check_can_update_timesheet():
def _check_can_write(self, values):
if not self.env.su and self.holiday_id:
raise UserError(_('You cannot modify timesheets that are linked to time off requests. Please use the Time Off application to modify your time off requests instead.'))
return super().write(vals)
return super()._check_can_write(values)
def _check_can_create(self):
if not self.env.su and any(task.is_timeoff_task for task in self.task_id):
raise UserError(_('You cannot create timesheets for a task that is linked to a time off type. Please use the Time Off application to request new time off instead.'))
return super()._check_can_create()
def _get_favorite_project_id_domain(self, employee_id=False):
return expression.AND([
return Domain.AND([
super()._get_favorite_project_id_domain(employee_id),
[('holiday_id', '=', False), ('global_leave_id', '=', False)],
Domain('holiday_id', '=', False),
Domain('global_leave_id', '=', False),
])

View file

@ -2,9 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from collections import defaultdict
class Employee(models.Model):
class HrEmployee(models.Model):
_inherit = 'hr.employee'
@api.model_create_multi
@ -20,36 +21,46 @@ class Employee(models.Model):
return employees
def write(self, vals):
result = super(Employee, self).write(vals)
if vals.get('active'):
inactive_emp = self.filtered(lambda e: not e.active)
result = super().write(vals)
self_company = self.with_context(allowed_company_ids=self.company_id.ids)
if 'active' in vals:
if vals.get('active'):
# Create future holiday timesheets
self_company._create_future_public_holidays_timesheets(self)
inactive_emp = inactive_emp.with_env(self_company.env)
inactive_emp._create_future_public_holidays_timesheets(inactive_emp)
else:
# Delete future holiday timesheets
self_company._delete_future_public_holidays_timesheets()
elif 'resource_calendar_id' in vals:
# Update future holiday timesheets
self_company._delete_future_public_holidays_timesheets()
self_company._create_future_public_holidays_timesheets(self)
self_company._create_future_public_holidays_timesheets(self_company)
return result
def _delete_future_public_holidays_timesheets(self):
future_timesheets = self.env['account.analytic.line'].sudo().search([('global_leave_id', '!=', False), ('date', '>=', fields.date.today()), ('employee_id', 'in', self.ids)])
future_timesheets = self.env['account.analytic.line'].sudo().search([('global_leave_id', '!=', False), ('date', '>=', fields.Date.today()), ('employee_id', 'in', self.ids)])
future_timesheets.write({'global_leave_id': False})
future_timesheets.unlink()
def _create_future_public_holidays_timesheets(self, employees):
lines_vals = []
today = fields.Datetime.today()
global_leaves_wo_calendar = defaultdict(lambda: self.env["resource.calendar.leaves"])
global_leaves_wo_calendar.update(dict(self.env['resource.calendar.leaves']._read_group(
[('calendar_id', '=', False), ('resource_id', '=', False), ('date_from', '>=', today)],
groupby=['company_id'],
aggregates=['id:recordset'],
)))
for employee in employees:
if not employee.active:
continue
# First we look for the global time off that are already planned after today
global_leaves = employee.resource_calendar_id.global_leave_ids.filtered(lambda l: l.date_from >= fields.Datetime.today())
global_leaves = employee.resource_calendar_id.global_leave_ids.filtered(lambda l: l.date_from >= today) + global_leaves_wo_calendar[employee.company_id]
work_hours_data = global_leaves._work_time_per_day()
for global_time_off in global_leaves:
for index, (day_date, work_hours_count) in enumerate(work_hours_data[global_time_off.id]):
for index, (day_date, work_hours_count) in enumerate(work_hours_data[employee.resource_calendar_id.id][global_time_off.id]):
lines_vals.append(
global_time_off._timesheet_prepare_line_values(
index,

View file

@ -1,142 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class HolidaysType(models.Model):
_inherit = "hr.leave.type"
def _default_project_id(self):
company = self.company_id if self.company_id else self.env.company
return company.internal_project_id.id
def _default_task_id(self):
company = self.company_id if self.company_id else self.env.company
return company.leave_timesheet_task_id.id
timesheet_generate = fields.Boolean(
'Generate Timesheet', compute='_compute_timesheet_generate', store=True, readonly=False,
help="If checked, when validating a time off, timesheet will be generated in the Vacation Project of the company.")
timesheet_project_id = fields.Many2one('project.project', string="Project", default=_default_project_id, domain="[('company_id', '=', company_id)]")
timesheet_task_id = fields.Many2one(
'project.task', string="Task", compute='_compute_timesheet_task_id',
store=True, readonly=False, default=_default_task_id,
domain="[('project_id', '=', timesheet_project_id),"
"('project_id', '!=', False),"
"('company_id', '=', company_id)]")
@api.depends('timesheet_task_id', 'timesheet_project_id')
def _compute_timesheet_generate(self):
for leave_type in self:
leave_type.timesheet_generate = leave_type.timesheet_task_id and leave_type.timesheet_project_id
@api.depends('timesheet_project_id')
def _compute_timesheet_task_id(self):
for leave_type in self:
company = leave_type.company_id if leave_type.company_id else self.env.company
default_task_id = company.leave_timesheet_task_id
if default_task_id and default_task_id.project_id == leave_type.timesheet_project_id:
leave_type.timesheet_task_id = default_task_id
else:
leave_type.timesheet_task_id = False
@api.constrains('timesheet_generate', 'timesheet_project_id', 'timesheet_task_id')
def _check_timesheet_generate(self):
for holiday_status in self:
if holiday_status.timesheet_generate:
if not holiday_status.timesheet_project_id or not holiday_status.timesheet_task_id:
raise ValidationError(_("Both the internal project and task are required to "
"generate a timesheet for the time off %s. If you don't want a timesheet, you should "
"leave the internal project and task empty.") % (holiday_status.name))
class Holidays(models.Model):
_inherit = "hr.leave"
timesheet_ids = fields.One2many('account.analytic.line', 'holiday_id', string="Analytic Lines")
def _validate_leave_request(self):
""" Timesheet will be generated on leave validation only if a timesheet_project_id and a
timesheet_task_id are set on the corresponding leave type. The generated timesheet will
be attached to this project/task.
"""
holidays = self.filtered(
lambda l: l.holiday_type == 'employee' and
l.holiday_status_id.timesheet_project_id and
l.holiday_status_id.timesheet_task_id and
l.holiday_status_id.timesheet_project_id.sudo().company_id == (l.holiday_status_id.company_id or self.env.company))
# Unlink previous timesheets do avoid doublon (shouldn't happen on the interface but meh)
old_timesheets = holidays.sudo().timesheet_ids
if old_timesheets:
old_timesheets.holiday_id = False
old_timesheets.unlink()
# create the timesheet on the vacation project
holidays._timesheet_create_lines()
return super()._validate_leave_request()
def _timesheet_create_lines(self):
vals_list = []
for leave in self:
if not leave.employee_id:
continue
work_hours_data = leave.employee_id.list_work_time_per_day(
leave.date_from,
leave.date_to)
for index, (day_date, work_hours_count) in enumerate(work_hours_data):
vals_list.append(leave._timesheet_prepare_line_values(index, work_hours_data, day_date, work_hours_count))
return self.env['account.analytic.line'].sudo().create(vals_list)
def _timesheet_prepare_line_values(self, index, work_hours_data, day_date, work_hours_count):
self.ensure_one()
return {
'name': _("Time Off (%s/%s)", index + 1, len(work_hours_data)),
'project_id': self.holiday_status_id.timesheet_project_id.id,
'task_id': self.holiday_status_id.timesheet_task_id.id,
'account_id': self.holiday_status_id.timesheet_project_id.analytic_account_id.id,
'unit_amount': work_hours_count,
'user_id': self.employee_id.user_id.id,
'date': day_date,
'holiday_id': self.id,
'employee_id': self.employee_id.id,
'company_id': self.holiday_status_id.timesheet_task_id.company_id.id or self.holiday_status_id.timesheet_project_id.company_id.id,
}
def _check_missing_global_leave_timesheets(self):
if not self:
return
min_date = min([leave.date_from for leave in self])
max_date = max([leave.date_to for leave in self])
global_leaves = self.env['resource.calendar.leaves'].search([
("resource_id", "=", False),
("date_to", ">=", min_date),
("date_from", "<=", max_date),
("calendar_id", "!=", False),
("company_id.internal_project_id", "!=", False),
("company_id.leave_timesheet_task_id", "!=", False),
])
if global_leaves:
global_leaves._generate_public_time_off_timesheets(self.sudo().employee_ids)
def action_refuse(self):
""" Remove the timesheets linked to the refused holidays """
result = super(Holidays, self).action_refuse()
timesheets = self.sudo().mapped('timesheet_ids')
timesheets.write({'holiday_id': False})
timesheets.unlink()
self._check_missing_global_leave_timesheets()
return result
def _action_user_cancel(self, reason):
res = super()._action_user_cancel(reason)
timesheets = self.sudo().timesheet_ids
timesheets.write({'holiday_id': False})
timesheets.unlink()
self._check_missing_global_leave_timesheets()
return res

View file

@ -0,0 +1,132 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
import pytz
class HrLeave(models.Model):
_inherit = "hr.leave"
timesheet_ids = fields.One2many('account.analytic.line', 'holiday_id', string="Analytic Lines")
def _validate_leave_request(self):
self._generate_timesheets()
return super()._validate_leave_request()
def _generate_timesheets(self, ignored_resource_calendar_leaves=None):
""" Timesheet will be generated on leave validation
internal_project_id and leave_timesheet_task_id are used.
The generated timesheet will be attached to this project/task.
"""
vals_list = []
leave_ids = []
calendar_leaves_data = self.env['resource.calendar.leaves']._read_group([('holiday_id', 'in', self.ids)], ['holiday_id'], ['id:array_agg'])
mapped_calendar_leaves = {leave: calendar_leave_ids[0] for leave, calendar_leave_ids in calendar_leaves_data}
for leave in self:
project, task = leave.employee_id.company_id.internal_project_id, leave.employee_id.company_id.leave_timesheet_task_id
if not project or not task or leave.holiday_status_id.time_type == 'other':
continue
leave_ids.append(leave.id)
if not leave.employee_id:
continue
calendar = leave.employee_id.resource_calendar_id
calendar_timezone = pytz.timezone((calendar or leave.employee_id).tz)
if calendar.flexible_hours and (leave.request_unit_hours or leave.request_unit_half or leave.date_from.date() == leave.date_to.date()):
leave_date = leave.date_from.astimezone(calendar_timezone).date()
if leave.request_unit_hours:
hours = leave.request_hour_to - leave.request_hour_from
elif leave.request_unit_half:
hours = calendar.hours_per_day / 2
else: # Single-day leave
hours = calendar.hours_per_day
work_hours_data = [(leave_date, hours)]
else:
ignored_resource_calendar_leaves = ignored_resource_calendar_leaves or []
if leave in mapped_calendar_leaves:
ignored_resource_calendar_leaves.append(mapped_calendar_leaves[leave])
work_hours_data = leave.employee_id._list_work_time_per_day(
leave.date_from,
leave.date_to,
domain=[('id', 'not in', ignored_resource_calendar_leaves)] if ignored_resource_calendar_leaves else None)[leave.employee_id.id]
for index, (day_date, work_hours_count) in enumerate(work_hours_data):
vals_list.append(leave._timesheet_prepare_line_values(index, work_hours_data, day_date, work_hours_count, project, task))
# Unlink previous timesheets to avoid doublon (shouldn't happen on the interface but meh). Necessary when the function is called to regenerate timesheets.
old_timesheets = self.env["account.analytic.line"].sudo().search([('project_id', '!=', False), ('holiday_id', 'in', leave_ids)])
if old_timesheets:
old_timesheets.holiday_id = False
old_timesheets.unlink()
self.env['account.analytic.line'].sudo().create(vals_list)
def _timesheet_prepare_line_values(self, index, work_hours_data, day_date, work_hours_count, project, task):
self.ensure_one()
return {
'name': _("Time Off (%(index)s/%(total)s)", index=index + 1, total=len(work_hours_data)),
'project_id': project.id,
'task_id': task.id,
'account_id': project.sudo().account_id.id,
'unit_amount': work_hours_count,
'user_id': self.employee_id.user_id.id,
'date': day_date,
'holiday_id': self.id,
'employee_id': self.employee_id.id,
'company_id': task.sudo().company_id.id or project.sudo().company_id.id,
}
def _check_missing_global_leave_timesheets(self):
if not self:
return
min_date = min(self.mapped('date_from'))
max_date = max(self.mapped('date_to'))
global_leaves = self.env['resource.calendar.leaves'].search([
("resource_id", "=", False),
("date_to", ">=", min_date),
("date_from", "<=", max_date),
("company_id.internal_project_id", "!=", False),
("company_id.leave_timesheet_task_id", "!=", False),
])
if global_leaves:
global_leaves._generate_public_time_off_timesheets(self.employee_id)
def action_refuse(self):
""" Remove the timesheets linked to the refused holidays """
result = super().action_refuse()
timesheets = self.sudo().mapped('timesheet_ids')
timesheets.write({'holiday_id': False})
timesheets.unlink()
self._check_missing_global_leave_timesheets()
return result
def _action_user_cancel(self, reason=None):
res = super()._action_user_cancel(reason)
timesheets = self.sudo().timesheet_ids
timesheets.write({'holiday_id': False})
timesheets.unlink()
self._check_missing_global_leave_timesheets()
return res
def _force_cancel(self, *args, **kwargs):
super()._force_cancel(*args, **kwargs)
# override this method to reevaluate timesheets after the leaves are updated via force cancel
timesheets = self.sudo().timesheet_ids
timesheets.holiday_id = False
timesheets.unlink()
def write(self, vals):
res = super().write(vals)
# reevaluate timesheets after the leaves are wrote in order to remove empty timesheets
timesheet_ids_to_remove = []
for leave in self:
if leave.number_of_days == 0 and leave.sudo().timesheet_ids:
leave.sudo().timesheet_ids.holiday_id = False
timesheet_ids_to_remove.extend(leave.timesheet_ids)
self.env['account.analytic.line'].browse(set(timesheet_ids_to_remove)).sudo().unlink()
return res

View file

@ -1,23 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo import fields, models
from odoo.fields import Domain
from odoo.tools import OrderedSet
class Task(models.Model):
class ProjectTask(models.Model):
_inherit = 'project.task'
leave_types_count = fields.Integer(compute='_compute_leave_types_count')
is_timeoff_task = fields.Boolean("Is Time off Task", compute="_compute_is_timeoff_task", search="_search_is_timeoff_task")
leave_types_count = fields.Integer(compute='_compute_leave_types_count', string="Time Off Types Count")
is_timeoff_task = fields.Boolean("Is Time off Task", compute="_compute_is_timeoff_task", search="_search_is_timeoff_task", export_string_translation=False, groups="hr_timesheet.group_hr_timesheet_user")
def _compute_leave_types_count(self):
time_off_type_read_group = self.env['hr.leave.type']._read_group(
[('timesheet_task_id', 'in', self.ids)],
['timesheet_task_id'],
['timesheet_task_id'],
timesheet_read_group = self.env['account.analytic.line']._read_group(
[('task_id', 'in', self.ids), '|', ('holiday_id', '!=', False), ('global_leave_id', '!=', False)],
['task_id'],
['__count'],
)
time_off_type_count_per_task = {res['timesheet_task_id'][0]: res['timesheet_task_id_count'] for res in time_off_type_read_group}
timesheet_count_per_task = {timesheet_task.id: count for timesheet_task, count in timesheet_read_group}
for task in self:
task.leave_types_count = time_off_type_count_per_task.get(task.id, 0)
task.leave_types_count = timesheet_count_per_task.get(task.id, 0)
def _compute_is_timeoff_task(self):
timeoff_tasks = self.filtered(lambda task: task.leave_types_count or task.company_id.leave_timesheet_task_id == task)
@ -25,16 +27,16 @@ class Task(models.Model):
(self - timeoff_tasks).is_timeoff_task = False
def _search_is_timeoff_task(self, operator, value):
if operator not in ['=', '!='] or not isinstance(value, bool):
raise NotImplementedError(_('Operation not supported'))
leave_type_read_group = self.env['hr.leave.type']._read_group(
[('timesheet_task_id', '!=', False)],
['timesheet_task_ids:array_agg(timesheet_task_id)'],
[],
)
timeoff_task_ids = leave_type_read_group[0]['timesheet_task_ids'] if leave_type_read_group[0]['timesheet_task_ids'] else []
if operator != 'in':
return NotImplemented
timeoff_tasks_ids = {row[0] for row in self.env.execute_query(
self.env['account.analytic.line']._search(
[('task_id', '!=', False), '|', ('holiday_id', '!=', False), ('global_leave_id', '!=', False)],
).select('DISTINCT task_id')
)}
if self.env.company.leave_timesheet_task_id:
timeoff_task_ids.append(self.env.company.leave_timesheet_task_id.id)
if operator == '!=':
value = not value
return [('id', 'in' if value else 'not in', timeoff_task_ids)]
timeoff_tasks_ids.add(self.env.company.leave_timesheet_task_id.id)
return Domain('id', 'in', tuple(timeoff_tasks_ids))

View file

@ -4,7 +4,7 @@
from odoo import fields, models, _
class Company(models.Model):
class ResCompany(models.Model):
_inherit = 'res.company'
leave_timesheet_task_id = fields.Many2one(

View file

@ -9,12 +9,12 @@ class ResConfigSettings(models.TransientModel):
internal_project_id = fields.Many2one(
related='company_id.internal_project_id', required=True, string="Internal Project",
domain="[('company_id', '=', company_id)]", readonly=False,
domain="[('company_id', '=', company_id), ('is_template', '=', False)]", readonly=False,
help="The default project used when automatically generating timesheets via time off requests."
" You can specify another project on each time off type individually.")
leave_timesheet_task_id = fields.Many2one(
related='company_id.leave_timesheet_task_id', string="Time Off Task", readonly=False,
domain="[('company_id', '=', company_id), ('project_id', '=?', internal_project_id)]",
domain="[('company_id', '=', company_id), ('project_id', '=?', internal_project_id), ('has_template_ancestor', '=', False)]",
help="The default task used when automatically generating timesheets via time off requests."
" You can specify another task on each time off type individually.")

View file

@ -10,9 +10,19 @@ from odoo import api, fields, models, _
class ResourceCalendarLeaves(models.Model):
_inherit = "resource.calendar.leaves"
timesheet_ids = fields.One2many('account.analytic.line', 'global_leave_id', string="Analytic Lines")
timesheet_ids = fields.One2many('account.analytic.line', 'global_leave_id', string="Analytic Lines", export_string_translation=False)
def _work_time_per_day(self):
def _get_resource_calendars(self):
leaves_with_calendar = self.filtered('calendar_id')
calendars = leaves_with_calendar.calendar_id
leaves_wo_calendar = self - leaves_with_calendar
if leaves_wo_calendar:
calendars += self.env['resource.calendar'].search([
('company_id', 'in', leaves_wo_calendar.company_id.ids + [False]),
])
return calendars
def _work_time_per_day(self, resource_calendars=False):
""" Get work time per day based on the calendar and its attendances
1) Gets all calendars with their characteristics (i.e.
@ -20,7 +30,7 @@ class ResourceCalendarLeaves(models.Model):
(b) the resources which have a leave,
(c) the oldest and
(d) the latest leave dates
) for leaves in self.
) for leaves in self (first for calendar's leaves, then for company's global leaves)
2) Search the attendances based on the characteristics retrieved for each calendar.
The attendances found are the ones between the date_from of the oldest leave
and the date_to of the most recent leave.
@ -32,28 +42,58 @@ class ResourceCalendarLeaves(models.Model):
}
}
"""
resource_calendars = resource_calendars or self._get_resource_calendars()
# to easily find the calendar with its id.
calendars_dict = {calendar.id: calendar for calendar in resource_calendars}
leaves_read_group = self.env['resource.calendar.leaves']._read_group(
[('id', 'in', self.ids)],
['calendar_id', 'ids:array_agg(id)', 'resource_ids:array_agg(resource_id)', 'min_date_from:min(date_from)', 'max_date_to:max(date_to)'],
[('id', 'in', self.ids), ('calendar_id', '!=', False)],
['calendar_id'],
['id:recordset', 'resource_id:recordset', 'date_from:min', 'date_to:max'],
)
# dict of keys: calendar_id
# and values : { 'date_from': datetime, 'date_to': datetime, resources: self.env['resource.resource'] }
cal_attendance_intervals_dict = {
res['calendar_id'][0]: {
'date_from': utc.localize(res['min_date_from']),
'date_to': utc.localize(res['max_date_to']),
'resources': self.env['resource.resource'].browse(res['resource_ids'] if res['resource_ids'] and res['resource_ids'][0] else []),
'leaves': self.env['resource.calendar.leaves'].browse(res['ids']),
} for res in leaves_read_group
}
# to easily find the calendar with its id.
calendars_dict = {calendar.id: calendar for calendar in self.calendar_id}
cal_attendance_intervals_dict = {}
for calendar, leaves, resources, date_from_min, date_to_max in leaves_read_group:
calendar_data = {
'date_from': utc.localize(date_from_min),
'date_to': utc.localize(date_to_max),
'resources': resources,
'leaves': leaves,
}
cal_attendance_intervals_dict[calendar.id] = calendar_data
# dict of keys: leave.id
# and values: a dict of keys: date
# and values: number of days
results = defaultdict(lambda: defaultdict(float))
comp_leaves_read_group = self.env['resource.calendar.leaves']._read_group(
[('id', 'in', self.ids), ('calendar_id', '=', False)],
['company_id'],
['id:recordset', 'resource_id:recordset', 'date_from:min', 'date_to:max'],
)
for company, leaves, resources, date_from_min, date_to_max in comp_leaves_read_group:
for calendar_id in resource_calendars.ids:
if (calendar_company := calendars_dict[calendar_id].company_id) and calendar_company != company:
continue # only consider global leaves of the same company as the calendar
calendar_data = cal_attendance_intervals_dict.get(calendar_id)
if calendar_data is None:
calendar_data = {
'date_from': utc.localize(date_from_min),
'date_to': utc.localize(date_to_max),
'resources': resources,
'leaves': leaves,
}
cal_attendance_intervals_dict[calendar_id] = calendar_data
else:
calendar_data.update(
date_from=min(utc.localize(date_from_min), calendar_data['date_from']),
date_to=max(utc.localize(date_to_max), calendar_data['date_to']),
resources=resources | calendar_data['resources'],
leaves=leaves | calendar_data['leaves'],
)
# dict of keys: calendar_id
# and values: a dict of keys: leave.id
# and values: a dict of keys: date
# and values: number of days
results = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))
for calendar_id, cal_attendance_intervals_params_entry in cal_attendance_intervals_dict.items():
calendar = calendars_dict[calendar_id]
work_hours_intervals = calendar._attendance_intervals_batch(
@ -65,12 +105,12 @@ class ResourceCalendarLeaves(models.Model):
for leave in cal_attendance_intervals_params_entry['leaves']:
work_hours_data = work_hours_intervals[leave.resource_id.id]
for date_from, date_to, dummy in work_hours_data:
for date_from, date_to, _dummy in work_hours_data:
if date_to > utc.localize(leave.date_from) and date_from < utc.localize(leave.date_to):
tmp_start = max(date_from, utc.localize(leave.date_from))
tmp_end = min(date_to, utc.localize(leave.date_to))
results[leave.id][tmp_start.date()] += (tmp_end - tmp_start).total_seconds() / 3600
results[leave.id] = sorted(results[leave.id].items())
results[calendar_id][leave.id][tmp_start.date()] += (tmp_end - tmp_start).total_seconds() / 3600
results[calendar_id][leave.id] = sorted(results[calendar_id][leave.id].items())
return results
def _timesheet_create_lines(self):
@ -78,43 +118,44 @@ class ResourceCalendarLeaves(models.Model):
If the employee has already a time off in the same day then no timesheet should be created.
"""
work_hours_data = self._work_time_per_day()
resource_calendars = self._get_resource_calendars()
work_hours_data = self._work_time_per_day(resource_calendars)
employees_groups = self.env['hr.employee']._read_group(
[('resource_calendar_id', 'in', self.calendar_id.ids), ('company_id', 'in', self.env.companies.ids)],
['resource_calendar_id', 'ids:array_agg(id)'],
['resource_calendar_id'])
[('resource_calendar_id', 'in', resource_calendars.ids), ('company_id', 'in', self.env.companies.ids)],
['resource_calendar_id'],
['id:recordset'])
mapped_employee = {
employee['resource_calendar_id'][0]: self.env['hr.employee'].browse(employee['ids'])
for employee in employees_groups
resource_calendar.id: employees
for resource_calendar, employees in employees_groups
}
employee_ids_set = set()
employee_ids_set.update(*[line['ids'] for line in employees_groups])
employee_ids_all = [_id for __, employees in employees_groups for _id in employees._ids]
min_date = max_date = None
for values in work_hours_data.values():
for d, dummy in values:
if not min_date and not max_date:
min_date = max_date = d
elif d < min_date:
min_date = d
elif d > max_date:
max_date = d
for vals in values.values():
for d, _dummy in vals:
if not min_date and not max_date:
min_date = max_date = d
elif d < min_date:
min_date = d
elif d > max_date:
max_date = d
holidays_read_group = self.env['hr.leave']._read_group([
('employee_id', 'in', list(employee_ids_set)),
('employee_id', 'in', employee_ids_all),
('date_from', '<=', max_date),
('date_to', '>=', min_date),
('state', '=', 'validate'),
], ['date_from_list:array_agg(date_from)', 'date_to_list:array_agg(date_to)', 'employee_id'], ['employee_id'])
], ['employee_id'], ['date_from:array_agg', 'date_to:array_agg'])
holidays_by_employee = {
line['employee_id'][0]: [
(date_from.date(), date_to.date()) for date_from, date_to in zip(line['date_from_list'], line['date_to_list'])
] for line in holidays_read_group
employee.id: [
(date_from.date(), date_to.date()) for date_from, date_to in zip(date_from_list, date_to_list)
] for employee, date_from_list, date_to_list in holidays_read_group
}
vals_list = []
for leave in self:
for employee in mapped_employee.get(leave.calendar_id.id, self.env['hr.employee']):
def get_timesheets_data(employees, work_hours_list, vals_list):
for employee in employees:
holidays = holidays_by_employee.get(employee.id)
work_hours_list = work_hours_data[leave.id]
for index, (day_date, work_hours_count) in enumerate(work_hours_list):
if not holidays or all(not (date_from <= day_date and date_to >= day_date) for date_from, date_to in holidays):
vals_list.append(
@ -126,15 +167,27 @@ class ResourceCalendarLeaves(models.Model):
work_hours_count
)
)
return vals_list
for leave in self:
if not leave.calendar_id:
for calendar_id, calendar_employees in mapped_employee.items():
work_hours_list = work_hours_data[calendar_id][leave.id]
vals_list = get_timesheets_data(calendar_employees, work_hours_list, vals_list)
else:
employees = mapped_employee.get(leave.calendar_id.id, self.env['hr.employee'])
work_hours_list = work_hours_data[leave.calendar_id.id][leave.id]
vals_list = get_timesheets_data(employees, work_hours_list, vals_list)
return self.env['account.analytic.line'].sudo().create(vals_list)
def _timesheet_prepare_line_values(self, index, employee_id, work_hours_data, day_date, work_hours_count):
self.ensure_one()
return {
'name': _("Time Off (%s/%s)", index + 1, len(work_hours_data)),
'name': _("Time Off (%(index)s/%(total)s)", index=index + 1, total=len(work_hours_data)),
'project_id': employee_id.company_id.internal_project_id.id,
'task_id': employee_id.company_id.leave_timesheet_task_id.id,
'account_id': employee_id.company_id.internal_project_id.analytic_account_id.id,
'account_id': employee_id.company_id.internal_project_id.account_id.id,
'unit_amount': work_hours_count,
'user_id': employee_id.user_id.id,
'date': day_date,
@ -143,23 +196,30 @@ class ResourceCalendarLeaves(models.Model):
'company_id': employee_id.company_id.id,
}
def _generate_timesheeets(self):
results_with_leave_timesheet = self.filtered(lambda r: not r.resource_id and r.company_id.internal_project_id and r.company_id.leave_timesheet_task_id)
if results_with_leave_timesheet:
results_with_leave_timesheet._timesheet_create_lines()
def _generate_public_time_off_timesheets(self, employees):
timesheet_vals_list = []
work_hours_data = self._work_time_per_day()
resource_calendars = self._get_resource_calendars()
work_hours_data = self._work_time_per_day(resource_calendars)
timesheet_read_group = self.env['account.analytic.line']._read_group(
[('global_leave_id', 'in', self.ids), ('employee_id', 'in', employees.ids)],
['date:array_agg'],
['employee_id']
['employee_id'],
['date:array_agg']
)
timesheet_dates_per_employee_id = {
res['employee_id'][0]: res['date']
for res in timesheet_read_group
employee.id: date
for employee, date in timesheet_read_group
}
for leave in self:
for employee in employees:
if employee.resource_calendar_id != leave.calendar_id:
if leave.calendar_id and employee.resource_calendar_id != leave.calendar_id:
continue
work_hours_list = work_hours_data[leave.id]
calendar = leave.calendar_id or employee.resource_calendar_id
work_hours_list = work_hours_data[calendar.id][leave.id]
timesheet_dates = timesheet_dates_per_employee_id.get(employee.id, [])
for index, (day_date, work_hours_count) in enumerate(work_hours_list):
generate_timesheet = day_date not in timesheet_dates
@ -175,24 +235,51 @@ class ResourceCalendarLeaves(models.Model):
timesheet_vals_list.append(timesheet_vals)
return self.env['account.analytic.line'].sudo().create(timesheet_vals_list)
def _get_overlapping_hr_leaves(self, domain=None):
"""Find leaves with potentially missing timesheets."""
self.ensure_one()
leave_domain = domain or []
leave_domain += [
('company_id', '=', self.company_id.id),
('date_from', '<=', self.date_to),
('date_to', '>=', self.date_from),
]
if self.calendar_id:
leave_domain += [('resource_calendar_id', 'in', [False, self.calendar_id.id])]
return self.env['hr.leave'].search(leave_domain)
@api.model_create_multi
def create(self, vals_list):
results = super(ResourceCalendarLeaves, self).create(vals_list)
results_with_leave_timesheet = results.filtered(lambda r: not r.resource_id.id and r.calendar_id.company_id.internal_project_id and r.calendar_id.company_id.leave_timesheet_task_id)
results_with_leave_timesheet and results_with_leave_timesheet._timesheet_create_lines()
results = super().create(vals_list)
results._generate_timesheeets()
return results
def write(self, vals):
date_from, date_to, calendar_id = vals.get('date_from'), vals.get('date_to'), vals.get('calendar_id')
global_time_off_updated = self.env['resource.calendar.leaves']
overlapping_leaves = self.env['hr.leave']
if date_from or date_to or 'calendar_id' in vals:
global_time_off_updated = self.filtered(lambda r: (date_from is not None and r.date_from != date_from) or (date_to is not None and r.date_to != date_to) or (calendar_id is not None and r.calendar_id.id != calendar_id))
global_time_off_updated = self.filtered(lambda r: (date_from is not None and r.date_from != date_from) or (date_to is not None and r.date_to != date_to) or (calendar_id is None or r.calendar_id.id != calendar_id))
timesheets = global_time_off_updated.sudo().timesheet_ids
if timesheets:
timesheets.write({'global_leave_id': False})
timesheets.unlink()
result = super(ResourceCalendarLeaves, self).write(vals)
if global_time_off_updated:
global_time_offs_with_leave_timesheet = global_time_off_updated.filtered(lambda r: not r.resource_id and r.calendar_id.company_id.internal_project_id and r.calendar_id.company_id.leave_timesheet_task_id)
global_time_offs_with_leave_timesheet.sudo()._timesheet_create_lines()
if calendar_id:
for gto in global_time_off_updated:
domain = [] if gto.calendar_id else [('resource_calendar_id', '!=', calendar_id)]
overlapping_leaves += gto._get_overlapping_hr_leaves(domain)
result = super().write(vals)
global_time_off_updated and global_time_off_updated.sudo()._generate_timesheeets()
if overlapping_leaves:
overlapping_leaves.sudo()._generate_timesheets()
return result
@api.ondelete(at_uninstall=False)
def _regenerate_hr_leave_timesheets_on_gto_unlinked(self):
overlapping_leaves = self.env['hr.leave']
global_leaves = self.filtered(lambda l: not l.resource_id)
for global_leave in global_leaves:
overlapping_leaves += global_leave._get_overlapping_hr_leaves()
if overlapping_leaves:
# we need to ignore the global time off since it hasn't been deleted yet
overlapping_leaves.sudo()._generate_timesheets(ignored_resource_calendar_leaves=global_leaves.ids)