mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-23 20:12:05 +02:00
19.0 vanilla
This commit is contained in:
parent
a1137a1456
commit
e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions
|
|
@ -2,5 +2,10 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_work_entry
|
||||
from . import resource
|
||||
from . import hr_work_entry_type
|
||||
from . import hr_user_work_entry_employee
|
||||
from . import resource_calendar
|
||||
from . import resource_calendar_attendance
|
||||
from . import resource_calendar_leaves
|
||||
from . import hr_employee
|
||||
from . import hr_version
|
||||
|
|
|
|||
|
|
@ -1,11 +1,38 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, _
|
||||
from odoo import models, fields, _
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
has_work_entries = fields.Boolean(compute='_compute_has_work_entries', groups="base.group_system,hr.group_hr_user")
|
||||
work_entry_source = fields.Selection(readonly=False, related="version_id.work_entry_source", inherited=True, groups="hr.group_hr_manager")
|
||||
work_entry_source_calendar_invalid = fields.Boolean(related="version_id.work_entry_source_calendar_invalid", inherited=True, groups="hr.group_hr_manager")
|
||||
|
||||
def _compute_has_work_entries(self):
|
||||
if self.ids:
|
||||
result = dict(self.env.execute_query(SQL(
|
||||
""" SELECT id, EXISTS(SELECT 1 FROM hr_work_entry WHERE employee_id = e.id LIMIT 1)
|
||||
FROM hr_employee e
|
||||
WHERE id in %s """,
|
||||
tuple(self.ids),
|
||||
)))
|
||||
else:
|
||||
result = {}
|
||||
|
||||
for employee in self:
|
||||
employee.has_work_entries = result.get(employee._origin.id, False)
|
||||
|
||||
def create_version(self, values):
|
||||
new_version = super().create_version(values)
|
||||
new_version.update({
|
||||
'date_generated_from': fields.Datetime.now().replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
'date_generated_to': fields.Datetime.now().replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
})
|
||||
return new_version
|
||||
|
||||
def action_open_work_entries(self, initial_date=False):
|
||||
self.ensure_one()
|
||||
ctx = {'default_employee_id': self.id}
|
||||
|
|
@ -14,8 +41,19 @@ class HrEmployee(models.Model):
|
|||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('%s work entries', self.display_name),
|
||||
'view_mode': 'calendar,tree,form',
|
||||
'view_mode': 'calendar,list,form',
|
||||
'res_model': 'hr.work.entry',
|
||||
'path': 'work-entries',
|
||||
'context': ctx,
|
||||
'domain': [('employee_id', '=', self.id)],
|
||||
}
|
||||
|
||||
def generate_work_entries(self, date_start, date_stop, force=False):
|
||||
date_start = fields.Date.to_date(date_start)
|
||||
date_stop = fields.Date.to_date(date_stop)
|
||||
|
||||
if self:
|
||||
versions = self._get_versions_with_contract_overlap_with_period(date_start, date_stop)
|
||||
else:
|
||||
versions = self._get_all_versions_with_contract_overlap_with_period(date_start, date_stop)
|
||||
return versions.generate_work_entries(date_start, date_stop, force=force)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrUserWorkEntryEmployee(models.Model):
|
||||
""" Personnal calendar filter """
|
||||
|
||||
_name = 'hr.user.work.entry.employee'
|
||||
_description = 'Work Entries Employees'
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user, ondelete='cascade')
|
||||
employee_id = fields.Many2one('hr.employee', 'Employee', required=True)
|
||||
active = fields.Boolean('Active', default=True)
|
||||
is_checked = fields.Boolean(default=True)
|
||||
|
||||
_user_id_employee_id_unique = models.Constraint(
|
||||
'UNIQUE(user_id,employee_id)',
|
||||
'You cannot have the same employee twice.',
|
||||
)
|
||||
|
|
@ -0,0 +1,727 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date, time, timedelta
|
||||
import pytz
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _, SUPERUSER_ID
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.fields import Command, Domain
|
||||
from odoo.tools import ormcache, float_is_zero
|
||||
from odoo.tools.intervals import Intervals
|
||||
|
||||
|
||||
class HrVersion(models.Model):
|
||||
_inherit = 'hr.version'
|
||||
|
||||
date_generated_from = fields.Datetime(string='Generated From', readonly=True, required=True,
|
||||
default=lambda self: datetime.now().replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
groups="hr.group_hr_user", tracking=True)
|
||||
date_generated_to = fields.Datetime(string='Generated To', readonly=True, required=True,
|
||||
default=lambda self: datetime.now().replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
groups="hr.group_hr_user", tracking=True)
|
||||
last_generation_date = fields.Date(string='Last Generation Date', readonly=True, groups="hr.group_hr_user", tracking=True)
|
||||
work_entry_source = fields.Selection([('calendar', 'Working Schedule')], required=True, default='calendar', tracking=True, help='''
|
||||
Defines the source for work entries generation
|
||||
|
||||
Working Schedule: Work entries will be generated from the working hours below.
|
||||
Attendances: Work entries will be generated from the employee's attendances. (requires Attendance app)
|
||||
Planning: Work entries will be generated from the employee's planning. (requires Planning app)
|
||||
''', groups="hr.group_hr_manager")
|
||||
work_entry_source_calendar_invalid = fields.Boolean(
|
||||
compute='_compute_work_entry_source_calendar_invalid',
|
||||
groups="hr.group_hr_manager",
|
||||
)
|
||||
|
||||
@api.depends('work_entry_source', 'resource_calendar_id')
|
||||
def _compute_work_entry_source_calendar_invalid(self):
|
||||
for version in self:
|
||||
version.work_entry_source_calendar_invalid = version.work_entry_source == 'calendar' and not version.resource_calendar_id
|
||||
|
||||
@ormcache()
|
||||
def _get_default_work_entry_type_id(self):
|
||||
attendance = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
|
||||
return attendance.id if attendance else False
|
||||
|
||||
@ormcache()
|
||||
def _get_default_work_entry_type_overtime_id(self):
|
||||
attendance = self.env.ref('hr_work_entry.work_entry_type_overtime', raise_if_not_found=False)
|
||||
return attendance.id if attendance else False
|
||||
|
||||
def _get_leave_work_entry_type_dates(self, leave, date_from, date_to, employee):
|
||||
return self._get_leave_work_entry_type(leave)
|
||||
|
||||
def _get_leave_work_entry_type(self, leave):
|
||||
return leave.work_entry_type_id
|
||||
|
||||
# Is used to add more values, for example planning_slot_id
|
||||
def _get_more_vals_attendance_interval(self, interval):
|
||||
return []
|
||||
|
||||
# Is used to add more values, for example leave_id (in hr_work_entry_holidays)
|
||||
def _get_more_vals_leave_interval(self, interval, leaves):
|
||||
return []
|
||||
|
||||
def _get_bypassing_work_entry_type_codes(self):
|
||||
return []
|
||||
|
||||
def _get_interval_leave_work_entry_type(self, interval, leaves, bypassing_codes):
|
||||
# returns the work entry time related to the leave that
|
||||
# includes the whole interval.
|
||||
# Overriden in hr_work_entry_holiday to select the
|
||||
# global time off first (eg: Public Holiday > Home Working)
|
||||
self.ensure_one()
|
||||
for leave in leaves:
|
||||
if interval[0] >= leave[0] and interval[1] <= leave[1] and leave[2]:
|
||||
interval_start = interval[0].astimezone(pytz.utc).replace(tzinfo=None)
|
||||
interval_stop = interval[1].astimezone(pytz.utc).replace(tzinfo=None)
|
||||
return self._get_leave_work_entry_type_dates(leave[2], interval_start, interval_stop, self.employee_id)
|
||||
return self.env.ref('hr_work_entry.work_entry_type_leave')
|
||||
|
||||
def _get_sub_leave_domain(self):
|
||||
return Domain('calendar_id', 'in', [False] + self.resource_calendar_id.ids)
|
||||
|
||||
def _get_leave_domain(self, start_dt, end_dt):
|
||||
domain = Domain([
|
||||
('resource_id', 'in', [False] + self.employee_id.resource_id.ids),
|
||||
('date_from', '<=', end_dt.replace(tzinfo=None)),
|
||||
('date_to', '>=', start_dt.replace(tzinfo=None)),
|
||||
('company_id', 'in', [False] + self.env.companies.ids),
|
||||
])
|
||||
return domain & self._get_sub_leave_domain()
|
||||
|
||||
def _get_resource_calendar_leaves(self, start_dt, end_dt):
|
||||
return self.env['resource.calendar.leaves'].search(self._get_leave_domain(start_dt, end_dt))
|
||||
|
||||
def _get_attendance_intervals(self, start_dt, end_dt):
|
||||
assert start_dt.tzinfo and end_dt.tzinfo, "function expects localized date"
|
||||
# {resource: intervals}
|
||||
employees_by_calendar = defaultdict(lambda: self.env['hr.employee'])
|
||||
for version in self:
|
||||
if version.work_entry_source != 'calendar':
|
||||
continue
|
||||
employees_by_calendar[version.resource_calendar_id] |= version.employee_id
|
||||
result = dict()
|
||||
for calendar, employees in employees_by_calendar.items():
|
||||
if not calendar:
|
||||
for employee in employees:
|
||||
result.update({employee.resource_id.id: Intervals([(start_dt, end_dt, self.env['resource.calendar.attendance'])])})
|
||||
else:
|
||||
result.update(calendar._attendance_intervals_batch(
|
||||
start_dt,
|
||||
end_dt,
|
||||
resources=employees.resource_id,
|
||||
tz=pytz.timezone(calendar.tz) if calendar.tz else pytz.utc
|
||||
))
|
||||
return result
|
||||
|
||||
def _get_lunch_intervals(self, start_dt, end_dt):
|
||||
# {resource: intervals}
|
||||
employees_by_calendar = defaultdict(lambda: self.env['hr.employee'])
|
||||
for version in self:
|
||||
employees_by_calendar[version.resource_calendar_id] |= version.employee_id
|
||||
result = {}
|
||||
for calendar, employees in employees_by_calendar.items():
|
||||
if not calendar:
|
||||
continue
|
||||
result.update(calendar._attendance_intervals_batch(
|
||||
start_dt,
|
||||
end_dt,
|
||||
resources=employees.resource_id,
|
||||
tz=pytz.timezone(calendar.tz),
|
||||
lunch=True,
|
||||
))
|
||||
return result
|
||||
|
||||
def _get_interval_work_entry_type(self, interval):
|
||||
self.ensure_one()
|
||||
if 'work_entry_type_id' in interval[2] and interval[2].work_entry_type_id[:1]:
|
||||
return interval[2].work_entry_type_id[:1]
|
||||
return self.env['hr.work.entry.type'].browse(self._get_default_work_entry_type_id())
|
||||
|
||||
def _get_valid_leave_intervals(self, attendances, interval):
|
||||
self.ensure_one()
|
||||
return [interval]
|
||||
|
||||
@api.model
|
||||
def _get_whitelist_fields_from_template(self):
|
||||
return super()._get_whitelist_fields_from_template() + ['work_entry_source']
|
||||
|
||||
# Meant for behavior override
|
||||
def _get_real_attendance_work_entry_vals(self, intervals):
|
||||
self.ensure_one()
|
||||
vals = []
|
||||
employee = self.employee_id
|
||||
for interval in intervals:
|
||||
work_entry_type = self._get_interval_work_entry_type(interval)
|
||||
# All benefits generated here are using datetimes converted from the employee's timezone
|
||||
vals += [dict([
|
||||
('name', "%s: %s" % (work_entry_type.name, employee.name)),
|
||||
('date_start', interval[0].astimezone(pytz.utc).replace(tzinfo=None)),
|
||||
('date_stop', interval[1].astimezone(pytz.utc).replace(tzinfo=None)),
|
||||
('work_entry_type_id', work_entry_type.id),
|
||||
('employee_id', employee.id),
|
||||
('version_id', self.id),
|
||||
('company_id', self.company_id.id),
|
||||
] + self._get_more_vals_attendance_interval(interval))]
|
||||
return vals
|
||||
|
||||
def _get_version_work_entries_values(self, date_start, date_stop):
|
||||
start_dt = pytz.utc.localize(date_start) if not date_start.tzinfo else date_start
|
||||
end_dt = pytz.utc.localize(date_stop) if not date_stop.tzinfo else date_stop
|
||||
version_vals = []
|
||||
bypassing_work_entry_type_codes = self._get_bypassing_work_entry_type_codes()
|
||||
|
||||
attendances_by_resource = self.sudo()._get_attendance_intervals(start_dt, end_dt)
|
||||
|
||||
resource_calendar_leaves = self._get_resource_calendar_leaves(start_dt, end_dt)
|
||||
# {resource: resource_calendar_leaves}
|
||||
leaves_by_resource = defaultdict(lambda: self.env['resource.calendar.leaves'])
|
||||
for leave in resource_calendar_leaves:
|
||||
leaves_by_resource[leave.resource_id.id] |= leave
|
||||
|
||||
tz_dates = {}
|
||||
for version in self:
|
||||
employee = version.employee_id
|
||||
calendar = version.resource_calendar_id
|
||||
resource = employee.resource_id
|
||||
# if the version is fully flexible, we refer to the employee's timezone
|
||||
tz = pytz.timezone(resource.tz) if version._is_fully_flexible() else pytz.timezone(calendar.tz)
|
||||
attendances = attendances_by_resource[resource.id]
|
||||
|
||||
# Other calendars: In case the employee has declared time off in another calendar
|
||||
# Example: Take a time off, then a credit time.
|
||||
resources_list = [self.env['resource.resource'], resource]
|
||||
leave_result = defaultdict(list)
|
||||
work_result = defaultdict(list)
|
||||
for leave in itertools.chain(leaves_by_resource[False], leaves_by_resource[resource.id]):
|
||||
for resource in resources_list:
|
||||
# Global time off is not for this calendar, can happen with multiple calendars in self
|
||||
if resource and leave.calendar_id and leave.calendar_id != calendar and not leave.resource_id:
|
||||
continue
|
||||
tz = tz if tz else pytz.timezone((resource or version).tz)
|
||||
if (tz, start_dt) in tz_dates:
|
||||
start = tz_dates[tz, start_dt]
|
||||
else:
|
||||
start = start_dt.astimezone(tz)
|
||||
tz_dates[tz, start_dt] = start
|
||||
if (tz, end_dt) in tz_dates:
|
||||
end = tz_dates[tz, end_dt]
|
||||
else:
|
||||
end = end_dt.astimezone(tz)
|
||||
tz_dates[tz, end_dt] = end
|
||||
dt0 = leave.date_from.astimezone(tz)
|
||||
dt1 = leave.date_to.astimezone(tz)
|
||||
leave_start_dt = max(start, dt0)
|
||||
leave_end_dt = min(end, dt1)
|
||||
leave_interval = (leave_start_dt, leave_end_dt, leave)
|
||||
leave_interval = version._get_valid_leave_intervals(attendances, leave_interval)
|
||||
if leave_interval:
|
||||
if leave.time_type == 'leave':
|
||||
leave_result[resource.id] += leave_interval
|
||||
else:
|
||||
work_result[resource.id] += leave_interval
|
||||
mapped_leaves = {r.id: Intervals(leave_result[r.id], keep_distinct=True) for r in resources_list}
|
||||
mapped_worked_leaves = {r.id: Intervals(work_result[r.id], keep_distinct=True) for r in resources_list}
|
||||
|
||||
leaves = mapped_leaves[resource.id]
|
||||
worked_leaves = mapped_worked_leaves[resource.id]
|
||||
|
||||
real_attendances = attendances - leaves - worked_leaves
|
||||
if not calendar:
|
||||
real_leaves = leaves
|
||||
real_worked_leaves = worked_leaves
|
||||
elif calendar.flexible_hours:
|
||||
# Flexible hours case
|
||||
# For multi day leaves, we want them to occupy the virtual working schedule 12 AM to average working days
|
||||
# For one day leaves, we want them to occupy exactly the time it was taken, for a time off in days
|
||||
# this will mean the virtual schedule and for time off in hours the chosen hours
|
||||
one_day_leaves = Intervals([l for l in leaves if l[0].date() == l[1].date()], keep_distinct=True)
|
||||
one_day_worked_leaves = Intervals([l for l in worked_leaves if l[0].date() == l[1].date()], keep_distinct=True)
|
||||
multi_day_leaves = leaves - one_day_leaves
|
||||
multi_day_worked_leaves = worked_leaves - one_day_worked_leaves
|
||||
static_attendances = calendar._attendance_intervals_batch(
|
||||
start_dt, end_dt, resources=resource, tz=tz)[resource.id]
|
||||
real_leaves = (static_attendances & multi_day_leaves) | one_day_leaves
|
||||
real_worked_leaves = (static_attendances & multi_day_worked_leaves) | one_day_worked_leaves
|
||||
|
||||
elif version.has_static_work_entries() or not leaves:
|
||||
# Empty leaves means empty real_leaves
|
||||
real_worked_leaves = attendances - real_attendances - leaves
|
||||
real_leaves = attendances - real_attendances - real_worked_leaves
|
||||
else:
|
||||
# In the case of attendance based versions use regular attendances to generate leave intervals
|
||||
static_attendances = calendar._attendance_intervals_batch(
|
||||
start_dt, end_dt, resources=resource, tz=tz)[resource.id]
|
||||
real_leaves = static_attendances & leaves
|
||||
real_worked_leaves = static_attendances & worked_leaves
|
||||
|
||||
real_attendances = self._get_real_attendances(attendances, leaves, worked_leaves)
|
||||
|
||||
if not version.has_static_work_entries():
|
||||
# An attendance based version might have an invalid planning, by definition it may not happen with
|
||||
# static work entries.
|
||||
# Creating overlapping slots for example might lead to a single work entry.
|
||||
# In that case we still create both work entries to indicate a problem (conflicting W E).
|
||||
split_attendances = []
|
||||
for attendance in real_attendances:
|
||||
if attendance[2] and len(attendance[2]) > 1:
|
||||
split_attendances += [(attendance[0], attendance[1], a) for a in attendance[2]]
|
||||
else:
|
||||
split_attendances += [attendance]
|
||||
real_attendances = split_attendances
|
||||
|
||||
# A leave period can be linked to several resource.calendar.leave
|
||||
split_leaves = []
|
||||
for leave_interval in leaves:
|
||||
if leave_interval[2] and len(leave_interval[2]) > 1:
|
||||
split_leaves += [(leave_interval[0], leave_interval[1], l) for l in leave_interval[2]]
|
||||
else:
|
||||
split_leaves += [(leave_interval[0], leave_interval[1], leave_interval[2])]
|
||||
leaves = split_leaves
|
||||
|
||||
split_worked_leaves = []
|
||||
for worked_leave_interval in real_worked_leaves:
|
||||
if worked_leave_interval[2] and len(worked_leave_interval[2]) > 1:
|
||||
split_worked_leaves += [(worked_leave_interval[0], worked_leave_interval[1], l) for l in worked_leave_interval[2]]
|
||||
else:
|
||||
split_worked_leaves += [(worked_leave_interval[0], worked_leave_interval[1], worked_leave_interval[2])]
|
||||
real_worked_leaves = split_worked_leaves
|
||||
|
||||
# Attendances
|
||||
version_vals += version._get_real_attendance_work_entry_vals(real_attendances)
|
||||
|
||||
for interval in real_worked_leaves:
|
||||
work_entry_type = version._get_interval_leave_work_entry_type(interval, worked_leaves, bypassing_work_entry_type_codes)
|
||||
# All benefits generated here are using datetimes converted from the employee's timezone
|
||||
version_vals += [dict([
|
||||
('name', "%s: %s" % (work_entry_type.name, employee.name)),
|
||||
('date_start', interval[0].astimezone(pytz.utc).replace(tzinfo=None)),
|
||||
('date_stop', interval[1].astimezone(pytz.utc).replace(tzinfo=None)),
|
||||
('work_entry_type_id', work_entry_type.id),
|
||||
('employee_id', employee.id),
|
||||
('version_id', version.id),
|
||||
('company_id', version.company_id.id),
|
||||
('state', 'draft'),
|
||||
] + version._get_more_vals_leave_interval(interval, worked_leaves))]
|
||||
|
||||
leaves_over_attendances = Intervals(leaves, keep_distinct=True) & real_leaves
|
||||
for interval in real_leaves:
|
||||
# Could happen when a leave is configured on the interface on a day for which the
|
||||
# employee is not supposed to work, i.e. no attendance_ids on the calendar.
|
||||
# In that case, do try to generate an empty work entry, as this would raise a
|
||||
# sql constraint error
|
||||
if interval[0] == interval[1]: # if start == stop
|
||||
continue
|
||||
leaves_over_interval = [l for l in leaves_over_attendances if l[0] >= interval[0] and l[1] <= interval[1]]
|
||||
for leave_interval in [(l[0], l[1], interval[2]) for l in leaves_over_interval]:
|
||||
leave_entry_type = version._get_interval_leave_work_entry_type(leave_interval, leaves, bypassing_work_entry_type_codes)
|
||||
interval_leaves = [leave for leave in leaves if leave[2].work_entry_type_id.id == leave_entry_type.id]
|
||||
if not interval_leaves:
|
||||
# Maybe the computed leave type is not found. In that case, we use all leaves
|
||||
interval_leaves = leaves
|
||||
interval_start = leave_interval[0].astimezone(pytz.utc).replace(tzinfo=None)
|
||||
interval_stop = leave_interval[1].astimezone(pytz.utc).replace(tzinfo=None)
|
||||
version_vals += [dict([
|
||||
('name', "%s%s" % (leave_entry_type.name + ": " if leave_entry_type else "", employee.name)),
|
||||
('date_start', interval_start),
|
||||
('date_stop', interval_stop),
|
||||
('work_entry_type_id', leave_entry_type.id),
|
||||
('employee_id', employee.id),
|
||||
('company_id', version.company_id.id),
|
||||
('version_id', version.id),
|
||||
] + version._get_more_vals_leave_interval(interval, interval_leaves))]
|
||||
return version_vals
|
||||
|
||||
# will override in attendance bridge to add overtime vals
|
||||
def _get_real_attendances(self, attendances, leaves, worked_leaves):
|
||||
return attendances - leaves - worked_leaves
|
||||
|
||||
def _get_work_entries_values(self, date_start, date_stop):
|
||||
"""
|
||||
Generate a work_entries list between date_start and date_stop for one version.
|
||||
:return: list of dictionnary.
|
||||
"""
|
||||
if isinstance(date_start, datetime):
|
||||
version_vals = self._get_version_work_entries_values(date_start, date_stop)
|
||||
else:
|
||||
version_vals = []
|
||||
versions_by_tz = defaultdict(lambda: self.env['hr.version'])
|
||||
for version in self:
|
||||
versions_by_tz[version.resource_calendar_id.tz] += version
|
||||
for version_tz, versions in versions_by_tz.items():
|
||||
tz = pytz.timezone(version_tz) if version_tz else pytz.utc
|
||||
version_vals += versions._get_version_work_entries_values(
|
||||
tz.localize(date_start),
|
||||
tz.localize(date_stop))
|
||||
|
||||
# {version_id: ([dates_start], [dates_stop])}
|
||||
mapped_version_dates = defaultdict(lambda: ([], []))
|
||||
for x in version_vals:
|
||||
mapped_version_dates[x['version_id']][0].append(x['date_start'])
|
||||
mapped_version_dates[x['version_id']][1].append(x['date_stop'])
|
||||
|
||||
for version in self:
|
||||
# If we generate work_entries which exceeds date_start or date_stop, we change boundaries on version
|
||||
if version_vals:
|
||||
# Handle empty work entries for certain versions, could happen on an attendance based version
|
||||
# NOTE: this does not handle date_stop or date_start not being present in vals
|
||||
dates_stop = mapped_version_dates[version.id][1]
|
||||
if dates_stop:
|
||||
date_stop_max = max(dates_stop)
|
||||
if date_stop_max > version.date_generated_to:
|
||||
version.date_generated_to = date_stop_max
|
||||
|
||||
dates_start = mapped_version_dates[version.id][0]
|
||||
if dates_start:
|
||||
date_start_min = min(dates_start)
|
||||
if date_start_min < version.date_generated_from:
|
||||
version.date_generated_from = date_start_min
|
||||
|
||||
return version_vals
|
||||
|
||||
def has_static_work_entries(self):
|
||||
# Static work entries as in the same are to be generated each month
|
||||
# Useful to differentiate attendance based versions from regular ones
|
||||
self.ensure_one()
|
||||
return self.work_entry_source == 'calendar'
|
||||
|
||||
def generate_work_entries(self, date_start, date_stop, force=False):
|
||||
# Generate work entries between 2 dates (datetime.date)
|
||||
# To correctly englobe the period, the start and end periods are converted
|
||||
# using the calendar timezone.
|
||||
assert not isinstance(date_start, datetime)
|
||||
assert not isinstance(date_stop, datetime)
|
||||
|
||||
date_start = datetime.combine(fields.Datetime.to_datetime(date_start), datetime.min.time())
|
||||
date_stop = datetime.combine(fields.Datetime.to_datetime(date_stop), datetime.max.time())
|
||||
|
||||
versions_by_company_tz = defaultdict(lambda: self.env['hr.version'])
|
||||
for version in self:
|
||||
versions_by_company_tz[
|
||||
version.company_id,
|
||||
(version.resource_calendar_id or version.employee_id).tz,
|
||||
] += version
|
||||
utc = pytz.timezone('UTC')
|
||||
new_work_entries = self.env['hr.work.entry']
|
||||
for (company, version_tz), versions in versions_by_company_tz.items():
|
||||
tz = pytz.timezone(version_tz) if version_tz else utc
|
||||
date_start_tz = tz.localize(date_start).astimezone(utc).replace(tzinfo=None)
|
||||
date_stop_tz = tz.localize(date_stop).astimezone(utc).replace(tzinfo=None)
|
||||
new_work_entries += versions.with_user(SUPERUSER_ID).with_company(company)._generate_work_entries(
|
||||
date_start_tz, date_stop_tz, force=force)
|
||||
return new_work_entries
|
||||
|
||||
def _generate_work_entries(self, date_start, date_stop, force=False):
|
||||
# Generate work entries between 2 dates (datetime.datetime)
|
||||
# This method considers that the dates are correctly localized
|
||||
# based on the target timezone
|
||||
assert isinstance(date_start, datetime)
|
||||
assert isinstance(date_stop, datetime)
|
||||
self = self.with_context(tracking_disable=True) # noqa: PLW0642
|
||||
vals_list = []
|
||||
self.write({'last_generation_date': fields.Date.today()})
|
||||
|
||||
intervals_to_generate = defaultdict(lambda: self.env['hr.version'])
|
||||
# In case the date_generated_from == date_generated_to, move it to the date_start to
|
||||
# avoid trying to generate several months/years of history for old versions for which
|
||||
# we've never generated the work entries.
|
||||
self.filtered(lambda c: c.date_generated_from == c.date_generated_to).write({
|
||||
'date_generated_from': date_start,
|
||||
'date_generated_to': date_start,
|
||||
})
|
||||
domain_to_nullify = Domain(False)
|
||||
work_entry_null_vals = {field: False for field in self.env["hr.work.entry.regeneration.wizard"]._work_entry_fields_to_nullify()}
|
||||
|
||||
for tz, versions in self.grouped("tz").items():
|
||||
tz = pytz.timezone(tz) if tz else pytz.utc
|
||||
for version in versions:
|
||||
if not version.contract_date_start:
|
||||
continue
|
||||
|
||||
version_start = tz.localize(fields.Datetime.to_datetime(version.date_start)).astimezone(pytz.utc).replace(tzinfo=None)
|
||||
version_stop = tz.localize(datetime.combine(fields.Datetime.to_datetime(version.date_end or date_stop),
|
||||
datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
||||
if version_stop < date_stop:
|
||||
if version.date_generated_from != version.date_generated_to:
|
||||
domain_to_nullify |= Domain([
|
||||
('version_id', '=', version.id),
|
||||
('date', '>', version_stop.astimezone(tz)),
|
||||
('date', '<=', date_stop.astimezone(tz)),
|
||||
('state', '!=', 'validated'),
|
||||
])
|
||||
if date_start > version_stop or date_stop < version_start:
|
||||
continue
|
||||
date_start_work_entries = max(date_start, version_start)
|
||||
date_stop_work_entries = min(date_stop, version_stop)
|
||||
if force:
|
||||
domain_to_nullify |= Domain([
|
||||
('version_id', '=', version.id),
|
||||
('date', '>=', date_start_work_entries.astimezone(tz).date()),
|
||||
('date', '<=', date_stop_work_entries.astimezone(tz).date()),
|
||||
('state', '!=', 'validated'),
|
||||
])
|
||||
intervals_to_generate[date_start_work_entries, date_stop_work_entries] |= version
|
||||
continue
|
||||
|
||||
# For each version, we found each interval we must generate
|
||||
# In some cases we do not want to set the generated dates beforehand, since attendance based work entries
|
||||
# is more dynamic, we want to update the dates within the _get_work_entries_values function
|
||||
last_generated_from = min(version.date_generated_from, version_stop)
|
||||
if last_generated_from > date_start_work_entries:
|
||||
version.date_generated_from = date_start_work_entries
|
||||
intervals_to_generate[date_start_work_entries, last_generated_from] |= version
|
||||
|
||||
last_generated_to = max(version.date_generated_to, version_start)
|
||||
if last_generated_to < date_stop_work_entries:
|
||||
version.date_generated_to = date_stop_work_entries
|
||||
intervals_to_generate[last_generated_to, date_stop_work_entries] |= version
|
||||
|
||||
for interval, versions in intervals_to_generate.items():
|
||||
date_from, date_to = interval
|
||||
vals_list.extend(versions._get_work_entries_values(date_from, date_to))
|
||||
|
||||
if domain_to_nullify != Domain.FALSE:
|
||||
work_entries_to_nullify = self.env['hr.work.entry'].search(domain_to_nullify)
|
||||
work_entries_to_nullify.write(work_entry_null_vals)
|
||||
|
||||
if not vals_list:
|
||||
return self.env['hr.work.entry']
|
||||
|
||||
vals_list = self._generate_work_entries_postprocess(vals_list)
|
||||
return self.env['hr.work.entry'].create(vals_list)
|
||||
|
||||
@api.model
|
||||
def _generate_work_entries_postprocess_adapt_to_calendar(self, vals):
|
||||
if 'work_entry_type_id' not in vals:
|
||||
return False
|
||||
return self.env['hr.work.entry.type'].browse(vals['work_entry_type_id']).is_leave
|
||||
|
||||
@api.model
|
||||
def _generate_work_entries_postprocess(self, vals_list):
|
||||
# Convert date_start/date_stop to date/duration
|
||||
# Split work entries over 2 days due to timezone conversion
|
||||
# Regroup work entries of the same type
|
||||
mapped_periods = defaultdict(lambda: defaultdict(lambda: self.env['hr.employee']))
|
||||
cached_periods = defaultdict(float)
|
||||
tz_by_version = {}
|
||||
|
||||
def _get_tz(version_id):
|
||||
if version_id in tz_by_version:
|
||||
return tz_by_version[version_id]
|
||||
version = self.env['hr.version'].browse(version_id)
|
||||
tz = version.resource_calendar_id.tz or version.employee_id.resource_calendar_id.tz or version.company_id.resource_calendar_id.tz
|
||||
if not tz:
|
||||
raise UserError(_('Missing timezone for work entries generation.'))
|
||||
tz = pytz.timezone(tz)
|
||||
tz_by_version[version_id] = tz
|
||||
return tz
|
||||
|
||||
new_vals_list = []
|
||||
for vals in vals_list:
|
||||
new_vals = vals.copy()
|
||||
if not new_vals.get('date_start') or not new_vals.get('date_stop'):
|
||||
new_vals.pop('date_start', False)
|
||||
new_vals.pop('date_stop', False)
|
||||
if 'duration' not in new_vals or 'date' not in new_vals:
|
||||
raise UserError(_('Missing date or duration on work entry'))
|
||||
new_vals_list.append(new_vals)
|
||||
continue
|
||||
|
||||
date_start_utc = new_vals['date_start'] if new_vals['date_start'].tzinfo else pytz.UTC.localize(new_vals['date_start'])
|
||||
date_stop_utc = new_vals['date_stop'] if new_vals['date_stop'].tzinfo else pytz.UTC.localize(new_vals['date_stop'])
|
||||
|
||||
tz = _get_tz(new_vals['version_id'])
|
||||
local_start = date_start_utc.astimezone(tz)
|
||||
local_stop = date_stop_utc.astimezone(tz)
|
||||
|
||||
# Handle multi-local-day spans
|
||||
current = local_start + timedelta(microseconds=1) if local_start.time() == datetime.max.time() else local_start
|
||||
while current < local_stop:
|
||||
next_local_midnight = tz.localize(datetime.combine(current.date() + timedelta(days=1), time.min) - timedelta(microseconds=1))
|
||||
segment_end = min(local_stop, next_local_midnight)
|
||||
|
||||
partial_vals = new_vals.copy()
|
||||
|
||||
# Convert partial segment back to UTC for consistency
|
||||
partial_vals['date_start'] = current.astimezone(pytz.UTC)
|
||||
partial_vals['date_stop'] = segment_end.astimezone(pytz.UTC)
|
||||
|
||||
new_vals_list.append(partial_vals)
|
||||
|
||||
current = segment_end + timedelta(microseconds=1)
|
||||
|
||||
vals_list = new_vals_list
|
||||
|
||||
for vals in vals_list:
|
||||
if not vals.get('date_start') or not vals.get('date_stop'):
|
||||
continue
|
||||
date_start = vals['date_start']
|
||||
date_stop = vals['date_stop']
|
||||
tz = _get_tz(vals['version_id'])
|
||||
if not self._generate_work_entries_postprocess_adapt_to_calendar(vals):
|
||||
vals['date'] = date_start.astimezone(tz).date()
|
||||
if 'duration' in vals:
|
||||
continue
|
||||
elif (date_start, date_stop) in cached_periods:
|
||||
vals['duration'] = cached_periods[date_start, date_stop]
|
||||
else:
|
||||
dt = date_stop - date_start
|
||||
duration = round(dt.total_seconds()) / 3600 # Number of hours
|
||||
cached_periods[date_start, date_stop] = duration
|
||||
vals['duration'] = duration
|
||||
continue
|
||||
version = self.env['hr.version'].browse(vals['version_id'])
|
||||
calendar = version.resource_calendar_id
|
||||
if not calendar:
|
||||
vals['date'] = date_start.astimezone(tz).date()
|
||||
vals['duration'] = 0.0
|
||||
continue
|
||||
employee = version.employee_id
|
||||
mapped_periods[date_start, date_stop][calendar] |= employee
|
||||
|
||||
# {(date_start, date_stop): {calendar: {'hours': foo}}}
|
||||
mapped_version_data = defaultdict(lambda: defaultdict(lambda: {'hours': 0.0}))
|
||||
for (date_start, date_stop), employees_by_calendar in mapped_periods.items():
|
||||
for calendar, employees in employees_by_calendar.items():
|
||||
mapped_version_data[date_start, date_stop][calendar] = employees._get_work_days_data_batch(
|
||||
date_start, date_stop, compute_leaves=False, calendar=calendar)
|
||||
|
||||
for vals in vals_list:
|
||||
if 'duration' not in vals:
|
||||
date_start = vals['date_start']
|
||||
date_stop = vals['date_stop']
|
||||
version = self.env['hr.version'].browse(vals['version_id'])
|
||||
calendar = version.resource_calendar_id
|
||||
employee = version.employee_id
|
||||
tz = _get_tz(vals['version_id'])
|
||||
vals['date'] = date_start.astimezone(tz).date()
|
||||
vals['duration'] = mapped_version_data[date_start, date_stop][calendar][employee.id]['hours'] if calendar else 0.0
|
||||
vals.pop('date_start', False)
|
||||
vals.pop('date_stop', False)
|
||||
|
||||
# Now merge similar work entries on the same day
|
||||
merged_vals = {}
|
||||
for vals in vals_list:
|
||||
if float_is_zero(vals['duration'], 3):
|
||||
continue
|
||||
key = (
|
||||
vals['date'],
|
||||
vals.get('work_entry_type_id', False),
|
||||
vals['employee_id'],
|
||||
vals['version_id'],
|
||||
vals.get('company_id', False),
|
||||
)
|
||||
if key in merged_vals:
|
||||
merged_vals[key]['duration'] += vals.get('duration', 0.0)
|
||||
else:
|
||||
merged_vals[key] = vals.copy()
|
||||
return list(merged_vals.values())
|
||||
|
||||
def _remove_work_entries(self):
|
||||
''' Remove all work_entries that are outside contract period (function used after writing new start or/and end date) '''
|
||||
all_we_to_unlink = self.env['hr.work.entry']
|
||||
for version in self:
|
||||
date_start = fields.Datetime.to_datetime(version.date_start)
|
||||
if version.date_generated_from < date_start:
|
||||
we_to_remove = self.env['hr.work.entry'].search([('date', '<', date_start), ('version_id', '=', version.id)])
|
||||
if we_to_remove:
|
||||
version.date_generated_from = date_start
|
||||
all_we_to_unlink |= we_to_remove
|
||||
if not version.date_end:
|
||||
continue
|
||||
date_end = datetime.combine(version.date_end, datetime.max.time())
|
||||
if version.date_generated_to > date_end:
|
||||
we_to_remove = self.env['hr.work.entry'].search([('date', '>', date_end), ('version_id', '=', version.id)])
|
||||
if we_to_remove:
|
||||
version.date_generated_to = date_end
|
||||
all_we_to_unlink |= we_to_remove
|
||||
all_we_to_unlink.unlink()
|
||||
|
||||
def _cancel_work_entries(self):
|
||||
if not self:
|
||||
return
|
||||
domains = []
|
||||
for version in self:
|
||||
date_start = fields.Datetime.to_datetime(version.date_start)
|
||||
version_domain = Domain([
|
||||
('version_id', '=', version.id),
|
||||
('date', '>=', date_start),
|
||||
])
|
||||
if version.date_end:
|
||||
date_end = datetime.combine(version.date_end, datetime.max.time())
|
||||
version_domain &= Domain('date', '<=', date_end)
|
||||
domains.append(version_domain)
|
||||
domain = Domain.OR(domains) & Domain('state', '!=', 'validated')
|
||||
work_entries = self.env['hr.work.entry'].sudo().search(domain)
|
||||
if work_entries:
|
||||
work_entries.unlink()
|
||||
|
||||
def write(self, vals):
|
||||
result = super().write(vals)
|
||||
if self.env.context.get('salary_simulation'):
|
||||
return result
|
||||
if vals.get('contract_date_end') or vals.get('contract_date_start') or vals.get('date_version'):
|
||||
self.sudo()._remove_work_entries()
|
||||
dependent_fields = self._get_fields_that_recompute_we()
|
||||
if any(key in dependent_fields for key in vals):
|
||||
for version_sudo in self.sudo():
|
||||
date_from = max(version_sudo.date_start, version_sudo.date_generated_from.date())
|
||||
date_to = min(version_sudo.date_end or date.max, version_sudo.date_generated_to.date())
|
||||
if date_from != date_to and self.employee_id:
|
||||
version_sudo._recompute_work_entries(date_from, date_to)
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
self._cancel_work_entries()
|
||||
return super().unlink()
|
||||
|
||||
def _recompute_work_entries(self, date_from, date_to):
|
||||
self.ensure_one()
|
||||
if self.employee_id:
|
||||
wizard = self.env['hr.work.entry.regeneration.wizard'].create({
|
||||
'employee_ids': [Command.set(self.employee_id.ids)],
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
})
|
||||
wizard.with_context(work_entry_skip_validation=True, active_test=False).regenerate_work_entries()
|
||||
|
||||
def _get_fields_that_recompute_we(self):
|
||||
# Returns the fields that should recompute the work entries
|
||||
return ['resource_calendar_id', 'work_entry_source']
|
||||
|
||||
@api.model
|
||||
def _cron_generate_missing_work_entries(self):
|
||||
# retrieve versions for the current month
|
||||
today = fields.Date.today()
|
||||
start = datetime.combine(today + relativedelta(day=1), time.min)
|
||||
stop = datetime.combine(today + relativedelta(months=1, day=31), time.max)
|
||||
all_versions = self.env['hr.employee']._get_all_versions_with_contract_overlap_with_period(start.date(), stop.date())
|
||||
# determine versions to do (the ones whose generated dates have open periods this month)
|
||||
versions_todo = all_versions.filtered(
|
||||
lambda v:
|
||||
(v.date_generated_from > start or v.date_generated_to < stop) and
|
||||
(not v.last_generation_date or v.last_generation_date < today))
|
||||
if not versions_todo:
|
||||
return
|
||||
version_todo_count = len(versions_todo)
|
||||
# Filter versions by company, work entries generation is not supposed to be called on
|
||||
# versions from differents companies, as we will retrieve the resource.calendar.leave
|
||||
# and we don't want to mix everything up. The other versions will be treated when the
|
||||
# cron is re-triggered
|
||||
versions_todo = versions_todo.filtered(lambda v: v.company_id == versions_todo[0].company_id)
|
||||
# generate a batch of work entries
|
||||
BATCH_SIZE = 100
|
||||
# Since attendance based are more volatile for their work entries generation
|
||||
# it can happen that the date_generated_from and date_generated_to fields are not
|
||||
# pushed to start and stop
|
||||
# It is more interesting for batching to process statically generated work entries first
|
||||
# since we get benefits from having multiple versions on the same calendar
|
||||
versions_todo = versions_todo.sorted(key=lambda v: 1 if v.has_static_work_entries() else 100)
|
||||
versions_todo = versions_todo[:BATCH_SIZE].generate_work_entries(start.date(), stop.date(), False)
|
||||
# if necessary, retrigger the cron to generate more work entries
|
||||
if version_todo_count > BATCH_SIZE:
|
||||
self.env.ref('hr_work_entry.ir_cron_generate_missing_work_entries')._trigger()
|
||||
|
|
@ -1,68 +1,68 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, time, timedelta
|
||||
from itertools import chain
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import pytz
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
from odoo import _, api, fields, models, tools
|
||||
from odoo.osv import expression
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import float_compare
|
||||
from odoo.tools.intervals import Intervals
|
||||
|
||||
|
||||
class HrWorkEntry(models.Model):
|
||||
_name = 'hr.work.entry'
|
||||
_description = 'HR Work Entry'
|
||||
_order = 'conflict desc,state,date_start'
|
||||
_order = 'create_date'
|
||||
|
||||
name = fields.Char(required=True, compute='_compute_name', store=True, readonly=False)
|
||||
name = fields.Char()
|
||||
active = fields.Boolean(default=True)
|
||||
employee_id = fields.Many2one('hr.employee', required=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", index=True)
|
||||
date_start = fields.Datetime(required=True, string='From')
|
||||
date_stop = fields.Datetime(compute='_compute_date_stop', store=True, readonly=False, string='To')
|
||||
duration = fields.Float(compute='_compute_duration', store=True, string="Duration", readonly=False)
|
||||
work_entry_type_id = fields.Many2one('hr.work.entry.type', index=True, default=lambda self: self.env['hr.work.entry.type'].search([], limit=1))
|
||||
version_id = fields.Many2one('hr.version', string="Employee Record", required=True, index=True)
|
||||
work_entry_source = fields.Selection(related='version_id.work_entry_source')
|
||||
date = fields.Date(required=True)
|
||||
duration = fields.Float(string="Duration", default=8)
|
||||
work_entry_type_id = fields.Many2one(
|
||||
'hr.work.entry.type',
|
||||
index=True,
|
||||
default=lambda self: self.env['hr.work.entry.type'].search([], limit=1),
|
||||
domain=lambda self: self._get_work_entry_type_domain())
|
||||
display_code = fields.Char(related='work_entry_type_id.display_code')
|
||||
code = fields.Char(related='work_entry_type_id.code')
|
||||
external_code = fields.Char(related='work_entry_type_id.external_code')
|
||||
color = fields.Integer(related='work_entry_type_id.color', readonly=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('validated', 'Validated'),
|
||||
('conflict', 'Conflict'),
|
||||
('draft', 'New'),
|
||||
('conflict', 'In Conflict'),
|
||||
('validated', 'In Payslip'),
|
||||
('cancelled', 'Cancelled')
|
||||
], default='draft')
|
||||
company_id = fields.Many2one('res.company', string='Company', readonly=True, required=True,
|
||||
default=lambda self: self.env.company)
|
||||
conflict = fields.Boolean('Conflicts', compute='_compute_conflict', store=True) # Used to show conflicting work entries first
|
||||
department_id = fields.Many2one('hr.department', related='employee_id.department_id', store=True)
|
||||
amount_rate = fields.Float("Pay rate")
|
||||
country_id = fields.Many2one('res.country', related='employee_id.company_id.country_id', search='_search_country_id')
|
||||
|
||||
# There is no way for _error_checking() to detect conflicts in work
|
||||
# entries that have been introduced in concurrent transactions, because of the transaction
|
||||
# isolation.
|
||||
# So if 2 transactions create work entries in parallel it is possible to create a conflict
|
||||
# that will not be visible by either transaction. There is no way to detect conflicts
|
||||
# between different records in a safe manner unless a SQL constraint is used, e.g. via
|
||||
# an EXCLUSION constraint [1]. This (obscure) type of constraint allows comparing 2 rows
|
||||
# using special operator classes and it also supports partial WHERE clauses. Similarly to
|
||||
# CHECK constraints, it's backed by an index.
|
||||
# 1: https://www.postgresql.org/docs/9.6/sql-createtable.html#SQL-CREATETABLE-EXCLUDE
|
||||
_sql_constraints = [
|
||||
('_work_entry_has_end', 'check (date_stop IS NOT NULL)', 'Work entry must end. Please define an end date or a duration.'),
|
||||
('_work_entry_start_before_end', 'check (date_stop > date_start)', 'Starting time should be before end time.'),
|
||||
(
|
||||
'_work_entries_no_validated_conflict',
|
||||
"""
|
||||
EXCLUDE USING GIST (
|
||||
tsrange(date_start, date_stop, '()') WITH &&,
|
||||
int4range(employee_id, employee_id, '[]') WITH =
|
||||
)
|
||||
WHERE (state = 'validated' AND active = TRUE)
|
||||
""",
|
||||
'Validated work entries cannot overlap'
|
||||
),
|
||||
]
|
||||
# FROM 7s by query to 2ms (with 2.6 millions entries)
|
||||
_contract_date_start_stop_idx = models.Index("(version_id, date) WHERE state IN ('draft', 'validated')")
|
||||
|
||||
def init(self):
|
||||
tools.create_index(self._cr, "hr_work_entry_date_start_date_stop_index", self._table, ["date_start", "date_stop"])
|
||||
@api.constrains('duration')
|
||||
def _check_duration(self):
|
||||
for work_entry in self:
|
||||
if float_compare(work_entry.duration, 0, 3) <= 0 or float_compare(work_entry.duration, 24, 3) > 0:
|
||||
raise ValidationError(self.env._("Duration must be positive and cannot exceed 24 hours."))
|
||||
|
||||
@api.depends('display_code', 'duration')
|
||||
def _compute_display_name(self):
|
||||
for work_entry in self:
|
||||
duration = str(timedelta(hours=work_entry.duration)).split(":")
|
||||
work_entry.display_name = "%s - %sh%s" % (work_entry.work_entry_type_id.name, duration[0], duration[1])
|
||||
|
||||
@api.depends('work_entry_type_id', 'employee_id')
|
||||
def _compute_name(self):
|
||||
|
|
@ -77,45 +77,41 @@ class HrWorkEntry(models.Model):
|
|||
for rec in self:
|
||||
rec.conflict = rec.state == 'conflict'
|
||||
|
||||
@api.depends('date_stop', 'date_start')
|
||||
def _compute_duration(self):
|
||||
durations = self._get_duration_batch()
|
||||
for work_entry in self:
|
||||
work_entry.duration = durations[work_entry.id]
|
||||
@api.onchange('employee_id', 'date')
|
||||
def _onchange_version_id(self):
|
||||
vals = {
|
||||
'employee_id': self.employee_id.id,
|
||||
'date': self.date,
|
||||
}
|
||||
try:
|
||||
res = self._set_current_contract(vals)
|
||||
except ValidationError:
|
||||
return
|
||||
if version_id := res.get('version_id'):
|
||||
self.version_id = version_id
|
||||
|
||||
@api.depends('date_start', 'duration')
|
||||
def _compute_date_stop(self):
|
||||
for work_entry in self.filtered(lambda w: w.date_start and w.duration):
|
||||
work_entry.date_stop = work_entry.date_start + relativedelta(hours=work_entry.duration)
|
||||
@api.model
|
||||
def _set_current_contract(self, vals):
|
||||
if not vals.get('version_id') and vals.get('date') and vals.get('employee_id'):
|
||||
employee = self.env['hr.employee'].browse(vals.get('employee_id'))
|
||||
active_version = employee._get_version(vals['date'])
|
||||
return dict(vals, version_id=active_version.id)
|
||||
return vals
|
||||
|
||||
def _get_duration_batch(self):
|
||||
result = {}
|
||||
cached_periods = defaultdict(float)
|
||||
for work_entry in self:
|
||||
date_start = work_entry.date_start
|
||||
date_stop = work_entry.date_stop
|
||||
if not date_start or not date_stop:
|
||||
result[work_entry.id] = 0.0
|
||||
continue
|
||||
if (date_start, date_stop) in cached_periods:
|
||||
result[work_entry.id] = cached_periods[(date_start, date_stop)]
|
||||
else:
|
||||
dt = date_stop - date_start
|
||||
duration = dt.days * 24 + dt.seconds / 3600 # Number of hours
|
||||
cached_periods[(date_start, date_stop)] = duration
|
||||
result[work_entry.id] = duration
|
||||
return result
|
||||
|
||||
# YTI TODO: Remove me in master: Deprecated, use _get_duration_batch instead
|
||||
def _get_duration(self, date_start, date_stop):
|
||||
return self._get_duration_batch()[self.id]
|
||||
@api.model
|
||||
def get_unusual_days(self, date_from, date_to=None):
|
||||
return self.env.company.resource_calendar_id._get_unusual_days(
|
||||
datetime.combine(fields.Date.from_string(date_from), time.min).replace(tzinfo=pytz.utc),
|
||||
datetime.combine(fields.Date.from_string(date_to), time.max).replace(tzinfo=pytz.utc),
|
||||
self.company_id,
|
||||
)
|
||||
|
||||
def action_validate(self):
|
||||
"""
|
||||
Try to validate work entries.
|
||||
If some errors are found, set `state` to conflict for conflicting work entries
|
||||
and validation fails.
|
||||
:return: True if validation succeded
|
||||
:return: True if validation succeeded
|
||||
"""
|
||||
work_entries = self.filtered(lambda work_entry: work_entry.state != 'validated')
|
||||
if not work_entries._check_if_error():
|
||||
|
|
@ -123,52 +119,135 @@ class HrWorkEntry(models.Model):
|
|||
return True
|
||||
return False
|
||||
|
||||
def action_split(self, vals):
|
||||
self.ensure_one()
|
||||
if self.duration < 1:
|
||||
raise UserError(self.env._("You can't split a work entry with less than 1 hour."))
|
||||
split_duration = vals['duration']
|
||||
if self.duration <= split_duration:
|
||||
raise UserError(
|
||||
self.env._(
|
||||
"Split work entry duration has to be less than the existing work entry duration."
|
||||
)
|
||||
)
|
||||
self.duration -= split_duration
|
||||
split_work_entry = self.copy()
|
||||
split_work_entry.write(vals)
|
||||
return split_work_entry.id
|
||||
|
||||
def _check_if_error(self):
|
||||
if not self:
|
||||
return False
|
||||
undefined_type = self.filtered(lambda b: not b.work_entry_type_id)
|
||||
undefined_type.write({'state': 'conflict'})
|
||||
conflict = self._mark_conflicting_work_entries(min(self.mapped('date_start')), max(self.mapped('date_stop')))
|
||||
return undefined_type or conflict
|
||||
conflict = self._mark_conflicting_work_entries(min(self.mapped('date')), max(self.mapped('date')))
|
||||
outside_calendar = self._mark_leaves_outside_schedule()
|
||||
already_validated_days = self._mark_already_validated_days()
|
||||
return undefined_type or conflict or outside_calendar or already_validated_days
|
||||
|
||||
def _mark_conflicting_work_entries(self, start, stop):
|
||||
"""
|
||||
Set `state` to `conflict` for overlapping work entries
|
||||
between two dates.
|
||||
If `self.ids` is truthy then check conflicts with the corresponding work entries.
|
||||
Return True if overlapping work entries were detected.
|
||||
Set `state` to `conflict` for work entries where, for the same employee and day,
|
||||
the total duration exceeds 24 hours.
|
||||
Return True if such entries are found.
|
||||
"""
|
||||
# Use the postgresql range type `tsrange` which is a range of timestamp
|
||||
# It supports the intersection operator (&&) useful to detect overlap.
|
||||
# use '()' to exlude the lower and upper bounds of the range.
|
||||
# Filter on date_start and date_stop (both indexed) in the EXISTS clause to
|
||||
# limit the resulting set size and fasten the query.
|
||||
self.flush_model(['date_start', 'date_stop', 'employee_id', 'active'])
|
||||
self.flush_model(['date', 'duration', 'employee_id', 'active'])
|
||||
query = """
|
||||
SELECT b1.id,
|
||||
b2.id
|
||||
FROM hr_work_entry b1
|
||||
JOIN hr_work_entry b2
|
||||
ON b1.employee_id = b2.employee_id
|
||||
AND b1.id <> b2.id
|
||||
WHERE b1.date_start <= %(stop)s
|
||||
AND b1.date_stop >= %(start)s
|
||||
AND b1.active = TRUE
|
||||
AND b2.active = TRUE
|
||||
AND tsrange(b1.date_start, b1.date_stop, '()') && tsrange(b2.date_start, b2.date_stop, '()')
|
||||
AND {}
|
||||
""".format("b2.id IN %(ids)s" if self.ids else "b2.date_start <= %(stop)s AND b2.date_stop >= %(start)s")
|
||||
self.env.cr.execute(query, {"stop": stop, "start": start, "ids": tuple(self.ids)})
|
||||
conflicts = set(itertools.chain.from_iterable(self.env.cr.fetchall()))
|
||||
self.browse(conflicts).write({
|
||||
'state': 'conflict',
|
||||
WITH excessive_days AS (
|
||||
SELECT employee_id, date
|
||||
FROM hr_work_entry
|
||||
WHERE active = TRUE
|
||||
AND date BETWEEN %(start)s AND %(stop)s
|
||||
AND employee_id IN %(employee_ids)s
|
||||
GROUP BY employee_id, date
|
||||
HAVING 0 >= SUM(duration) OR SUM(duration) > 24
|
||||
)
|
||||
SELECT we.id
|
||||
FROM hr_work_entry we
|
||||
JOIN excessive_days ed
|
||||
ON we.employee_id = ed.employee_id
|
||||
AND we.date = ed.date
|
||||
WHERE we.active = TRUE
|
||||
"""
|
||||
self.env.cr.execute(query, {
|
||||
"start": start,
|
||||
"stop": stop,
|
||||
'employee_ids': tuple(self.employee_id.ids),
|
||||
})
|
||||
return bool(conflicts)
|
||||
conflict_ids = [row[0] for row in self.env.cr.fetchall()]
|
||||
self.browse(conflict_ids).write({'state': 'conflict'})
|
||||
return bool(conflict_ids)
|
||||
|
||||
def _get_leaves_entries_outside_schedule(self):
|
||||
return self.filtered(lambda w: w.work_entry_type_id.is_leave and w.state not in ('validated', 'cancelled'))
|
||||
|
||||
def _mark_leaves_outside_schedule(self):
|
||||
"""
|
||||
Check leave work entries in `self` which are completely outside
|
||||
the contract's theoretical calendar schedule. Mark them as conflicting.
|
||||
:return: leave work entries completely outside the contract's calendar
|
||||
"""
|
||||
work_entries = self._get_leaves_entries_outside_schedule()
|
||||
entries_by_calendar = defaultdict(lambda: self.env['hr.work.entry'])
|
||||
for work_entry in work_entries:
|
||||
calendar = work_entry.version_id.resource_calendar_id
|
||||
entries_by_calendar[calendar] |= work_entry
|
||||
|
||||
outside_entries = self.env['hr.work.entry']
|
||||
for calendar, entries in entries_by_calendar.items():
|
||||
if not calendar or calendar.flexible_hours:
|
||||
continue
|
||||
datetime_start = datetime.combine(min(entries.mapped('date')), time.min)
|
||||
datetime_stop = datetime.combine(max(entries.mapped('date')), time.max)
|
||||
|
||||
if calendar:
|
||||
calendar_intervals = calendar._attendance_intervals_batch(pytz.utc.localize(datetime_start), pytz.utc.localize(datetime_stop))[False]
|
||||
else:
|
||||
calendar_intervals = Intervals([(pytz.utc.localize(datetime_start), pytz.utc.localize(datetime_stop), self.env['resource.calendar.attendance'])])
|
||||
entries_intervals = entries._to_intervals()
|
||||
overlapping_entries = self._from_intervals(entries_intervals & calendar_intervals)
|
||||
outside_entries |= entries - overlapping_entries
|
||||
outside_entries.write({'state': 'conflict'})
|
||||
return bool(outside_entries)
|
||||
|
||||
def _mark_already_validated_days(self):
|
||||
invalid_entries = self.env['hr.work.entry']
|
||||
validated_work_entries = self.env["hr.work.entry"].search([
|
||||
('state', '=', 'validated'),
|
||||
('date', '<=', max(self.mapped('date'))),
|
||||
('date', '>=', min(self.mapped('date'))),
|
||||
('company_id', '=', self.env.company.id)
|
||||
])
|
||||
validated_entries_by_employee_date = defaultdict(lambda: self.env['hr.work.entry'])
|
||||
for entry in validated_work_entries:
|
||||
validated_entries_by_employee_date[entry.employee_id, entry.date] += entry
|
||||
|
||||
for entry in self:
|
||||
if validated_entries_by_employee_date[entry.employee_id, entry.date]:
|
||||
invalid_entries += entry
|
||||
invalid_entries.write({'state': 'conflict'})
|
||||
return bool(invalid_entries)
|
||||
|
||||
def _to_intervals(self):
|
||||
return Intervals(
|
||||
((datetime.combine(w.date, time.min).replace(tzinfo=pytz.utc), datetime.combine(w.date, time.max).replace(tzinfo=pytz.utc), w) for w in self),
|
||||
keep_distinct=True)
|
||||
|
||||
@api.model
|
||||
def _from_intervals(self, intervals):
|
||||
return self.browse(chain.from_iterable(recs.ids for start, end, recs in intervals))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
vals_list = [self._set_current_contract(vals) for vals in vals_list]
|
||||
company_by_employee_id = {}
|
||||
for vals in vals_list:
|
||||
if (
|
||||
not 'amount_rate' in vals
|
||||
and (work_entry_type_id := vals.get('work_entry_type_id'))
|
||||
):
|
||||
work_entry_type = self.env['hr.work.entry.type'].browse(work_entry_type_id)
|
||||
vals['amount_rate'] = work_entry_type.amount_rate
|
||||
if vals.get('company_id'):
|
||||
continue
|
||||
if vals['employee_id'] not in company_by_employee_id:
|
||||
|
|
@ -180,7 +259,7 @@ class HrWorkEntry(models.Model):
|
|||
return work_entries
|
||||
|
||||
def write(self, vals):
|
||||
skip_check = not bool({'date_start', 'date_stop', 'employee_id', 'work_entry_type_id', 'active'} & vals.keys())
|
||||
skip_check = not bool({'date', 'duration', 'employee_id', 'work_entry_type_id', 'active'} & vals.keys())
|
||||
if 'state' in vals:
|
||||
if vals['state'] == 'draft':
|
||||
vals['active'] = True
|
||||
|
|
@ -195,7 +274,12 @@ class HrWorkEntry(models.Model):
|
|||
if 'employee_id' in vals and vals['employee_id']:
|
||||
employee_ids += [vals['employee_id']]
|
||||
with self._error_checking(skip=skip_check, employee_ids=employee_ids):
|
||||
return super(HrWorkEntry, self).write(vals)
|
||||
return super().write(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_validated_work_entries(self):
|
||||
if any(w.state == 'validated' for w in self):
|
||||
raise UserError(_("This work entry is validated. You can't delete it."))
|
||||
|
||||
def unlink(self):
|
||||
employee_ids = self.employee_id.ids
|
||||
|
|
@ -219,16 +303,16 @@ class HrWorkEntry(models.Model):
|
|||
"""
|
||||
try:
|
||||
skip = skip or self.env.context.get('hr_work_entry_no_check', False)
|
||||
start = start or min(self.mapped('date_start'), default=False)
|
||||
stop = stop or max(self.mapped('date_stop'), default=False)
|
||||
start = start or min(self.mapped('date'), default=False)
|
||||
stop = stop or max(self.mapped('date'), default=False)
|
||||
if not skip and start and stop:
|
||||
domain = [
|
||||
('date_start', '<', stop),
|
||||
('date_stop', '>', start),
|
||||
('state', 'not in', ('validated', 'cancelled')),
|
||||
]
|
||||
domain = (
|
||||
Domain('date', '<=', stop)
|
||||
& Domain('date', '>=', start)
|
||||
& Domain('state', 'not in', ('validated', 'cancelled'))
|
||||
)
|
||||
if employee_ids:
|
||||
domain = expression.AND([domain, [('employee_id', 'in', list(employee_ids))]])
|
||||
domain &= Domain('employee_id', 'in', list(employee_ids))
|
||||
work_entries = self.sudo().with_context(hr_work_entry_no_check=True).search(domain)
|
||||
work_entries._reset_conflicting_state()
|
||||
yield
|
||||
|
|
@ -243,34 +327,10 @@ class HrWorkEntry(models.Model):
|
|||
# no need to reload work entries.
|
||||
work_entries.exists()._check_if_error()
|
||||
|
||||
def _get_work_entry_type_domain(self):
|
||||
if len(self.env.companies.country_id.ids) > 1:
|
||||
return [('country_id', '=', False)]
|
||||
return ['|', ('country_id', '=', False), ('country_id', 'in', self.env.companies.country_id.ids)]
|
||||
|
||||
class HrWorkEntryType(models.Model):
|
||||
_name = 'hr.work.entry.type'
|
||||
_description = 'HR Work Entry Type'
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
code = fields.Char(required=True, help="Careful, the Code is used in many references, changing it could lead to unwanted changes.")
|
||||
color = fields.Integer(default=0)
|
||||
sequence = fields.Integer(default=25)
|
||||
active = fields.Boolean(
|
||||
'Active', default=True,
|
||||
help="If the active field is set to false, it will allow you to hide the work entry type without removing it.")
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_work_entry_code', 'UNIQUE(code)', 'The same code cannot be associated to multiple work entry types.'),
|
||||
]
|
||||
|
||||
|
||||
class Contacts(models.Model):
|
||||
""" Personnal calendar filter """
|
||||
|
||||
_name = 'hr.user.work.entry.employee'
|
||||
_description = 'Work Entries Employees'
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user)
|
||||
employee_id = fields.Many2one('hr.employee', 'Employee', required=True)
|
||||
active = fields.Boolean('Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('user_id_employee_id_unique', 'UNIQUE(user_id,employee_id)', 'You cannot have the same employee twice.')
|
||||
]
|
||||
def _search_country_id(self, operator, value):
|
||||
return [('employee_id.company_id.partner_id.country_id', operator, value)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class HrWorkEntryType(models.Model):
|
||||
_name = 'hr.work.entry.type'
|
||||
_description = 'HR Work Entry Type'
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
display_code = fields.Char(string="Display Code", size=3, translate=True, help="This code can be changed, it is only for a display purpose (3 letters max)")
|
||||
code = fields.Char(string="Payroll Code", required=True, help="Careful, the Code is used in many references, changing it could lead to unwanted changes.")
|
||||
external_code = fields.Char(help="Use this code to export your data to a third party")
|
||||
color = fields.Integer(default=0)
|
||||
sequence = fields.Integer(default=25)
|
||||
active = fields.Boolean(
|
||||
'Active', default=True,
|
||||
help="If the active field is set to false, it will allow you to hide the work entry type without removing it.")
|
||||
country_id = fields.Many2one(
|
||||
'res.country',
|
||||
string="Country",
|
||||
domain=lambda self: [('id', 'in', self.env.companies.country_id.ids)]
|
||||
)
|
||||
country_code = fields.Char(related='country_id.code')
|
||||
is_leave = fields.Boolean(
|
||||
default=False, string="Time Off", help="Allow the work entry type to be linked with time off types.")
|
||||
is_work = fields.Boolean(
|
||||
compute='_compute_is_work', inverse='_inverse_is_work', string="Working Time", readonly=False,
|
||||
help="If checked, the work entry is counted as work time in the working schedule")
|
||||
amount_rate = fields.Float(
|
||||
string="Rate",
|
||||
default=1.0,
|
||||
help="If you want the hours should be paid double, the rate should be 200%.")
|
||||
is_extra_hours = fields.Boolean(
|
||||
string="Added to Monthly Pay",
|
||||
help="Check this setting if you want the hours to be considered as extra time and added as a bonus to the basic salary.")
|
||||
|
||||
@api.constrains('country_id')
|
||||
def _check_work_entry_type_country(self):
|
||||
if self.env.ref('hr_work_entry.work_entry_type_attendance') in self:
|
||||
raise UserError(_("You can't change the country of this specific work entry type."))
|
||||
elif not self.env.context.get('install_mode') and self.env['hr.work.entry'].sudo().search_count([('work_entry_type_id', 'in', self.ids)], limit=1):
|
||||
raise UserError(_("You can't change the Country of this work entry type cause it's currently used by the system. You need to delete related working entries first."))
|
||||
|
||||
@api.constrains('code', 'country_id')
|
||||
def _check_code_unicity(self):
|
||||
similar_work_entry_types = self.search([
|
||||
('code', 'in', self.mapped('code')),
|
||||
('country_id', 'in', self.country_id.ids + [False]),
|
||||
('id', 'not in', self.ids)
|
||||
])
|
||||
for work_entry_type in self:
|
||||
invalid_work_entry_types = similar_work_entry_types.filtered_domain([
|
||||
('code', '=', work_entry_type.code),
|
||||
('country_id', 'in', self.country_id.ids + [False]),
|
||||
])
|
||||
if invalid_work_entry_types:
|
||||
raise UserError(_("The same code cannot be associated to multiple work entry types (%s)", ', '.join(list(set(invalid_work_entry_types.mapped('code'))))))
|
||||
|
||||
@api.depends('is_leave')
|
||||
def _compute_is_work(self):
|
||||
for record in self:
|
||||
record.is_work = not record.is_leave
|
||||
|
||||
def _inverse_is_work(self):
|
||||
for record in self:
|
||||
record.is_leave = not record.is_work
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ResourceCalendar(models.Model):
|
||||
_inherit = 'resource.calendar'
|
||||
|
||||
# Override the method to add 'attendance_ids.work_entry_type_id.is_leave' to the dependencies
|
||||
@api.depends('attendance_ids.work_entry_type_id.is_leave')
|
||||
def _compute_hours_per_week(self):
|
||||
super()._compute_hours_per_week()
|
||||
|
||||
def _get_global_attendances(self):
|
||||
return super()._get_global_attendances().filtered(lambda a: not a.work_entry_type_id.is_leave)
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
|
@ -19,15 +18,5 @@ class ResourceCalendarAttendance(models.Model):
|
|||
res['work_entry_type_id'] = self.work_entry_type_id.id
|
||||
return res
|
||||
|
||||
|
||||
class ResourceCalendarLeave(models.Model):
|
||||
_inherit = 'resource.calendar.leaves'
|
||||
|
||||
work_entry_type_id = fields.Many2one(
|
||||
'hr.work.entry.type', 'Work Entry Type',
|
||||
groups="hr.group_hr_user")
|
||||
|
||||
def _copy_leave_vals(self):
|
||||
res = super()._copy_leave_vals()
|
||||
res['work_entry_type_id'] = self.work_entry_type_id.id
|
||||
return res
|
||||
def _is_work_period(self):
|
||||
return not self.work_entry_type_id.is_leave and super()._is_work_period()
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class ResourceCalendarLeaves(models.Model):
|
||||
_inherit = 'resource.calendar.leaves'
|
||||
|
||||
work_entry_type_id = fields.Many2one(
|
||||
'hr.work.entry.type', 'Work Entry Type',
|
||||
groups="hr.group_hr_user")
|
||||
|
||||
def _copy_leave_vals(self):
|
||||
res = super()._copy_leave_vals()
|
||||
res['work_entry_type_id'] = self.work_entry_type_id.id
|
||||
return res
|
||||
Loading…
Add table
Add a link
Reference in a new issue