301 lines
17 KiB
Python
301 lines
17 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
|
|
from datetime import datetime, timedelta
|
|
from dateutil.relativedelta import relativedelta
|
|
from pytz import timezone, utc
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.tools.intervals import Intervals
|
|
|
|
|
|
class HrVersion(models.Model):
|
|
_inherit = 'hr.version'
|
|
|
|
work_entry_source = fields.Selection(
|
|
selection_add=[('attendance', 'Attendances')],
|
|
ondelete={'attendance': 'set default'},
|
|
)
|
|
overtime_from_attendance = fields.Boolean(
|
|
"Extra hours", help="Add extra hours from attendances to the working entries", store=True, tracking=True,
|
|
readonly=False, compute='_compute_overtime_from_attendance', groups="hr.group_hr_manager")
|
|
|
|
def _get_overtime_intervals(self, start_dt, end_dt):
|
|
start_naive = start_dt.replace(tzinfo=None)
|
|
end_naive = end_dt.replace(tzinfo=None)
|
|
|
|
domain = [("manual_duration", ">", 0),
|
|
("employee_id", "=", self.employee_id.ids),
|
|
"|",
|
|
("time_stop", "<=", end_naive),
|
|
("time_stop", "=", False),
|
|
("time_start", ">=", start_naive),
|
|
]
|
|
|
|
overtimes = self.env['hr.attendance.overtime.line']._read_group(
|
|
domain=domain,
|
|
groupby=['employee_id', 'date:day'],
|
|
aggregates=['id:recordset']
|
|
)
|
|
overtimes_by_employee_by_date = defaultdict(dict)
|
|
for employee, date, overtime_lines in overtimes:
|
|
overtimes_by_employee_by_date[employee][date] = overtime_lines
|
|
res = {}
|
|
for employee, overtimes_by_date in overtimes_by_employee_by_date.items():
|
|
resource = employee.resource_id
|
|
overtime_list = []
|
|
for day, overtimes in overtimes_by_date.items():
|
|
overtimes = overtimes.sorted(lambda ot: ot.time_stop or ot.time_start)
|
|
prev_ot = None
|
|
for (check_in, check_out), ots in overtimes.grouped(lambda ot: (ot.time_start, ot.time_stop)).items():
|
|
prev_duration = 0 # to avoid intervals overlapping
|
|
for ot in ots:
|
|
tz = timezone(ot.employee_id.tz or resource.tz or 'UTC')
|
|
# Localization represents the LOCAL end of day for the date of the line
|
|
end_of_day_tz = tz.localize(datetime.combine(day + relativedelta(days=1), datetime.min.time()))
|
|
end_of_day = end_of_day_tz.astimezone(utc).replace(tzinfo=None)
|
|
start_of_day_tz = tz.localize(datetime.combine(day, datetime.min.time()))
|
|
start_of_day = start_of_day_tz.astimezone(utc).replace(tzinfo=None)
|
|
|
|
datetime_stop = (
|
|
min(end_of_day, ot.time_stop) if ot.time_stop else end_of_day
|
|
) - relativedelta(hours=prev_duration)
|
|
datetime_start = max(start_of_day, (datetime_stop - timedelta(hours=ot.duration)))
|
|
if prev_ot and prev_ot[0] < datetime_stop and datetime_start < prev_ot[1]:
|
|
datetime_start = prev_ot[1]
|
|
prev_ot = (datetime_start, datetime_stop)
|
|
prev_duration += ot.duration
|
|
overtime_list.append((utc.localize(datetime_start), utc.localize(datetime_stop), ot))
|
|
res[resource.id] = Intervals(overtime_list, keep_distinct=True)
|
|
return res
|
|
|
|
def _set_real_overtime_intervals(self, start_dt, end_dt, attendances, mapped_intervals, overtime_intervals):
|
|
""" This method split the overtime_intervals to not overlap the expected employee schedule or planning
|
|
Example: Working schedule : 8h-12h -> 13h-17h, Attendance = 7h-18h, Overtime duration = 2h
|
|
overtime_intervals will be equal to 16h-18h (because we only want to display one overtime line)
|
|
but the real overtime intervals is equal to 7h-8h and 17h-18h
|
|
"""
|
|
attendances_by_resources = attendances.grouped(lambda attendance: attendance.employee_id.resource_id.id)
|
|
lunch_by_resource = defaultdict()
|
|
version_by_resource_calendar = self.grouped('resource_calendar_id')
|
|
for calendar, versions in version_by_resource_calendar.items():
|
|
if not calendar:
|
|
continue
|
|
lunch_by_resource.update(calendar._attendance_intervals_batch(start_dt, end_dt, resources=versions.employee_id.resource_id, lunch=True))
|
|
for resource in mapped_intervals:
|
|
if resource not in attendances_by_resources:
|
|
continue
|
|
attendance_overtime_intersection = overtime_intervals[resource] & mapped_intervals[resource]
|
|
if not attendance_overtime_intersection:
|
|
continue
|
|
resource_attendance_intervals = Intervals([(
|
|
utc.localize(att.check_in), utc.localize(att.check_out), self.env['hr.attendance']
|
|
) for att in attendances_by_resources[resource]])
|
|
relevant_attendance_interval = resource_attendance_intervals & Intervals([(start_dt, end_dt, self.env['hr.attendance'])])
|
|
duration_remaining_by_overtime_line = defaultdict(float)
|
|
for stop, start, model in overtime_intervals[resource]:
|
|
duration_remaining_by_overtime_line[model] += model.duration
|
|
real_overtime_intervals = Intervals(keep_distinct=True)
|
|
outside_schedule_intervals = relevant_attendance_interval - mapped_intervals[resource]
|
|
# If there are some overtime line with some duration remaining after going through the outside working hours intervals,
|
|
# it means that some overtime line have been computed over normal attendance intervals. In that case, place the
|
|
# remaining overtime on the attendance intervals which are the mapped_intervals
|
|
for intervals in [outside_schedule_intervals, mapped_intervals[resource]]:
|
|
for start, stop, model in intervals:
|
|
interval_duration = (stop - start).total_seconds() / 3600
|
|
remaining_duration = interval_duration
|
|
current_position = start
|
|
|
|
for ot_line, duration in duration_remaining_by_overtime_line.items():
|
|
if remaining_duration <= 0:
|
|
break
|
|
if not duration:
|
|
continue
|
|
|
|
allocated_duration = min(remaining_duration, duration)
|
|
|
|
real_overtime_intervals |= Intervals([(
|
|
current_position,
|
|
current_position + relativedelta(hours=allocated_duration),
|
|
ot_line
|
|
)])
|
|
|
|
duration_remaining_by_overtime_line[ot_line] -= allocated_duration
|
|
remaining_duration -= allocated_duration
|
|
current_position = current_position + relativedelta(hours=allocated_duration)
|
|
|
|
overtime_intervals[resource] = real_overtime_intervals
|
|
|
|
def _get_attendance_intervals(self, start_dt, end_dt):
|
|
##################################
|
|
# ATTENDANCE BASED CONTRACTS #
|
|
##################################
|
|
start_naive = start_dt.replace(tzinfo=None)
|
|
end_naive = end_dt.replace(tzinfo=None)
|
|
overtime_contracts = self.filtered_domain(['|', ('work_entry_source', '=', 'attendance'), ('ruleset_id', '!=', False)])
|
|
attendance_based_contracts = self.filtered_domain([('work_entry_source', '=', 'attendance')])
|
|
search_domain = [
|
|
('employee_id', 'in', overtime_contracts.employee_id.ids),
|
|
('check_in', '<=', end_naive),
|
|
('check_out', '>=', start_naive), # We ignore attendances which don't have a check_out
|
|
]
|
|
resource_ids = attendance_based_contracts.employee_id.resource_id.ids
|
|
all_attendances = self.env['hr.attendance'].sudo().search(search_domain)
|
|
attendances = all_attendances.filtered_domain([('employee_id.work_entry_source', '=', 'attendance')]) if attendance_based_contracts\
|
|
else self.env['hr.attendance']
|
|
intervals = defaultdict(list)
|
|
calendar_by_employee = {}
|
|
leaves = {}
|
|
lunches = {}
|
|
employee_by_calendar = defaultdict(lambda: self.env['hr.employee'])
|
|
for version in self:
|
|
calendar = version.resource_calendar_id
|
|
if calendar and not calendar.flexible_hours:
|
|
calendar_by_employee[version.employee_id] = calendar
|
|
employee_by_calendar[calendar] += version.employee_id
|
|
for calendar, employees in employee_by_calendar.items():
|
|
leaves |= calendar._leave_intervals_batch(start_dt, end_dt, resources=employees.resource_id)
|
|
lunches |= calendar._attendance_intervals_batch(start_dt, end_dt, resources=employees.resource_id, lunch=True)
|
|
for attendance in attendances:
|
|
resource = attendance.employee_id.resource_id
|
|
tz = timezone(attendance.employee_id.tz or resource.tz) # refer to resource's tz if fully flexible resource (calendar is False)
|
|
check_in_tz = attendance.check_in.astimezone(tz)
|
|
check_out_tz = attendance.check_out.astimezone(tz)
|
|
if attendance.overtime_status == 'refused':
|
|
check_out_tz -= timedelta(hours=attendance.validated_overtime_hours)
|
|
|
|
resource_lunch = lunches.get(attendance.employee_id.resource_id.id, Intervals([]))
|
|
resource_leave = leaves.get(attendance.employee_id.resource_id.id, Intervals([]))
|
|
real_lunch_intervals = resource_lunch - resource_leave
|
|
attendance_intervals = Intervals([(check_in_tz, check_out_tz, attendance)]) - real_lunch_intervals
|
|
for interval in attendance_intervals:
|
|
intervals[resource.id].append((
|
|
max(start_dt, interval[0]),
|
|
min(end_dt, interval[1]),
|
|
attendance))
|
|
|
|
mapped_intervals = {r: Intervals(intervals[r], keep_distinct=True) for r in resource_ids}
|
|
mapped_intervals.update(super()._get_attendance_intervals(
|
|
start_dt, end_dt))
|
|
|
|
overtime_intervals = {r: Intervals(keep_distinct=True) for r in mapped_intervals}
|
|
overtime_intervals.update(overtime_contracts._get_overtime_intervals(start_dt, end_dt))
|
|
overtime_attendances = all_attendances.filtered_domain([('employee_id.ruleset_id', '!=', False), ('employee_id.work_entry_source', '=', 'calendar')])
|
|
overtime_contracts._set_real_overtime_intervals(start_dt, end_dt, overtime_attendances, mapped_intervals, overtime_intervals)
|
|
work_entry_overtime_intervals = defaultdict(list)
|
|
for r, intervals in overtime_intervals.items():
|
|
for start, end, overtime in intervals:
|
|
if not (overtime.rule_ids.work_entry_type_id and overtime.status == 'approved'):
|
|
continue
|
|
work_entry_overtime_intervals[r].extend([
|
|
(start, end, overtime)
|
|
])
|
|
|
|
result = {
|
|
r: (mapped_intervals[r] - overtime_intervals[r])
|
|
| Intervals(work_entry_overtime_intervals[r], keep_distinct=True)
|
|
for r in mapped_intervals
|
|
}
|
|
return result
|
|
|
|
def _get_valid_leave_intervals(self, attendances, interval):
|
|
self.ensure_one()
|
|
badge_attendances = Intervals([
|
|
(start, end, record) for (start, end, record) in attendances
|
|
if start <= interval[1] and end > interval[0] and isinstance(record, self.env['hr.attendance'].__class__)
|
|
], keep_distinct=True)
|
|
if badge_attendances:
|
|
leave_interval = Intervals([interval], keep_distinct=True)
|
|
return list(leave_interval - badge_attendances)
|
|
return super()._get_valid_leave_intervals(attendances, interval)
|
|
|
|
def _get_real_attendance_work_entry_vals(self, intervals):
|
|
self.ensure_one()
|
|
non_attendance_intervals = [interval for interval in intervals if interval[2]._name not in ['hr.attendance', 'hr.attendance.overtime.line']]
|
|
attendance_intervals = [interval for interval in intervals if interval[2]._name in ['hr.attendance', 'hr.attendance.overtime.line']]
|
|
if attendance_intervals:
|
|
default_overtime_type = self.env.ref('hr_work_entry.work_entry_type_overtime')
|
|
attendance_work_entry_type = self.env.ref('hr_work_entry.work_entry_type_attendance')
|
|
vals = super()._get_real_attendance_work_entry_vals(non_attendance_intervals)
|
|
|
|
employee = self.employee_id
|
|
for interval in attendance_intervals:
|
|
if interval[2]._name == 'hr.attendance':
|
|
work_entry_type = attendance_work_entry_type
|
|
# 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(utc).replace(tzinfo=None)),
|
|
('date_stop', interval[1].astimezone(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))]
|
|
elif interval[2]._name == 'hr.attendance.overtime.line':
|
|
overtime_mode = self.ruleset_id.rate_combination_mode
|
|
overtime_line_id = interval[2]
|
|
paid_rules = overtime_line_id.rule_ids.filtered('paid')
|
|
# skip creating overtime work entries when the ruleset has no paid rules
|
|
if not paid_rules:
|
|
continue
|
|
|
|
triggered_rule_work_entry_types = paid_rules.mapped('work_entry_type_id') or default_overtime_type
|
|
# Take into account manually encoded duration
|
|
date_start = interval[0].astimezone(utc).replace(tzinfo=None)
|
|
date_stop = interval[1].astimezone(utc).replace(tzinfo=None)
|
|
if overtime_mode == 'max' or len(triggered_rule_work_entry_types) == 1:
|
|
work_entry_type = max(triggered_rule_work_entry_types, key=lambda w: w.amount_rate)
|
|
# 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', date_start),
|
|
('date_stop', date_stop),
|
|
('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))]
|
|
else:
|
|
for triggered_rule in paid_rules:
|
|
# All benefits generated here are using datetimes converted from the employee's timezone
|
|
vals += [dict([
|
|
('name', "%s: %s" % (triggered_rule.work_entry_type_id.name, employee.name)),
|
|
('date_start', date_start),
|
|
('date_stop', date_stop),
|
|
('work_entry_type_id', triggered_rule.work_entry_type_id.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_more_vals_attendance_interval(self, interval):
|
|
vals = super()._get_more_vals_attendance_interval(interval)
|
|
if interval[2]._name == 'hr.attendance':
|
|
vals.append(('attendance_id', interval[2].id))
|
|
if interval[2]._name == 'hr.attendance.overtime.line':
|
|
vals.append(('overtime_id', interval[2].id))
|
|
return vals
|
|
|
|
def _get_real_attendances(self, attendances, leaves, worked_leaves):
|
|
attendances_list = []
|
|
no_attendances_list = []
|
|
for start, stop, model in attendances:
|
|
if model._name in ['hr.attendance', 'hr.attendance.overtime.line']:
|
|
attendances_list.append((start, stop, model))
|
|
else:
|
|
no_attendances_list.append((start, stop, model))
|
|
super_list = super()._get_real_attendances(Intervals(no_attendances_list, keep_distinct=True), leaves, worked_leaves)._items
|
|
return Intervals(attendances_list + super_list, keep_distinct=True)
|
|
|
|
@api.model
|
|
def _get_whitelist_fields_from_template(self):
|
|
return super()._get_whitelist_fields_from_template() + ['overtime_from_attendance']
|
|
|
|
@api.depends('ruleset_id')
|
|
def _compute_overtime_from_attendance(self):
|
|
for record in self:
|
|
record.overtime_from_attendance = bool(record.ruleset_id)
|