mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-26 23: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
|
|
@ -1,58 +1,199 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from operator import itemgetter
|
||||
|
||||
import pytz
|
||||
|
||||
from calendar import monthrange
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, time
|
||||
from dateutil.rrule import rrule, DAILY
|
||||
from dateutil.relativedelta import relativedelta, MO, SU
|
||||
from itertools import chain
|
||||
from operator import itemgetter
|
||||
from pytz import timezone, utc
|
||||
from random import randint
|
||||
|
||||
from odoo import models, fields, api, exceptions, _
|
||||
from odoo.tools import format_datetime
|
||||
from odoo.osv.expression import AND, OR
|
||||
from odoo.tools.float_utils import float_is_zero
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.fields import Domain
|
||||
from odoo.http import request
|
||||
from odoo.tools import convert, format_duration, format_time, format_datetime
|
||||
from odoo.tools.date_utils import sum_intervals
|
||||
from odoo.tools.intervals import Intervals
|
||||
|
||||
def get_google_maps_url(latitude, longitude):
|
||||
return "https://maps.google.com?q=%s,%s" % (latitude, longitude)
|
||||
|
||||
|
||||
class HrAttendance(models.Model):
|
||||
_name = "hr.attendance"
|
||||
_name = 'hr.attendance'
|
||||
_description = "Attendance"
|
||||
_order = "check_in desc"
|
||||
_inherit = ["mail.thread"]
|
||||
|
||||
def _default_employee(self):
|
||||
return self.env.user.employee_id
|
||||
if self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
return self.env.user.employee_id
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True)
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True,
|
||||
ondelete='cascade', index=True, group_expand='_read_group_employee_id')
|
||||
department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
|
||||
readonly=True)
|
||||
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True)
|
||||
check_out = fields.Datetime(string="Check Out")
|
||||
manager_id = fields.Many2one(comodel_name='hr.employee', related="employee_id.parent_id", readonly=True,
|
||||
export_string_translation=False)
|
||||
attendance_manager_id = fields.Many2one('res.users', related="employee_id.attendance_manager_id",
|
||||
export_string_translation=False)
|
||||
is_manager = fields.Boolean(compute="_compute_is_manager")
|
||||
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True, tracking=True, index=True)
|
||||
check_out = fields.Datetime(string="Check Out", tracking=True)
|
||||
date = fields.Date(string="Date", compute='_compute_date', store=True, index=True, precompute=True, required=True)
|
||||
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
|
||||
color = fields.Integer(compute='_compute_color')
|
||||
overtime_hours = fields.Float(string="Over Time", compute='_compute_overtime_hours', store=True)
|
||||
overtime_status = fields.Selection(selection=[('to_approve', "To Approve"),
|
||||
('approved', "Approved"),
|
||||
('refused', "Refused")], compute="_compute_overtime_status", store=True, tracking=True, readonly=False)
|
||||
validated_overtime_hours = fields.Float(string="Extra Hours", compute='_compute_validated_overtime_hours', tracking=True, store=True, readonly=True)
|
||||
in_latitude = fields.Float(string="Latitude", digits=(10, 7), readonly=True, aggregator=None)
|
||||
in_longitude = fields.Float(string="Longitude", digits=(10, 7), readonly=True, aggregator=None)
|
||||
in_location = fields.Char(help="Based on GPS-Coordinates if available or on IP Address")
|
||||
in_ip_address = fields.Char(string="IP Address", readonly=True)
|
||||
in_browser = fields.Char(string="Browser", readonly=True)
|
||||
in_mode = fields.Selection(string="Mode",
|
||||
selection=[('kiosk', "Kiosk"),
|
||||
('systray', "Systray"),
|
||||
('manual', "Manual"),
|
||||
('technical', 'Technical')],
|
||||
readonly=True,
|
||||
default='manual')
|
||||
out_latitude = fields.Float(digits=(10, 7), readonly=True, aggregator=None)
|
||||
out_longitude = fields.Float(digits=(10, 7), readonly=True, aggregator=None)
|
||||
out_location = fields.Char(help="Based on GPS-Coordinates if available or on IP Address")
|
||||
out_ip_address = fields.Char(readonly=True)
|
||||
out_browser = fields.Char(readonly=True)
|
||||
out_mode = fields.Selection(selection=[('kiosk', "Kiosk"),
|
||||
('systray', "Systray"),
|
||||
('manual', "Manual"),
|
||||
('technical', 'Technical'),
|
||||
('auto_check_out', 'Automatic Check-Out')],
|
||||
readonly=True,
|
||||
default='manual')
|
||||
expected_hours = fields.Float(compute="_compute_expected_hours", store=True, aggregator="sum")
|
||||
device_tracking_enabled = fields.Boolean(related="employee_id.company_id.attendance_device_tracking")
|
||||
linked_overtime_ids = fields.Many2many('hr.attendance.overtime.line', compute='_compute_linked_overtime_ids', readonly=False)
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
@api.depends("check_in", "employee_id")
|
||||
def _compute_date(self):
|
||||
for attendance in self:
|
||||
if not attendance.employee_id or not attendance.check_in: # weird precompute edge cases. Never after creation
|
||||
attendance.date = datetime.today()
|
||||
continue
|
||||
tz = timezone(attendance.employee_id._get_tz())
|
||||
attendance.date = utc.localize(attendance.check_in).astimezone(tz).date()
|
||||
|
||||
@api.depends("worked_hours", "overtime_hours")
|
||||
def _compute_expected_hours(self):
|
||||
for attendance in self:
|
||||
attendance.expected_hours = attendance.worked_hours - attendance.overtime_hours
|
||||
|
||||
def _compute_color(self):
|
||||
for attendance in self:
|
||||
if attendance.check_out:
|
||||
attendance.color = 1 if attendance.worked_hours > 16 or attendance.out_mode == 'technical' else 0
|
||||
else:
|
||||
attendance.color = 1 if attendance.check_in < (datetime.today() - timedelta(days=1)) else 10
|
||||
|
||||
@api.depends('check_in', 'check_out', 'employee_id')
|
||||
def _compute_overtime_status(self):
|
||||
for attendance in self:
|
||||
if not attendance.linked_overtime_ids:
|
||||
attendance.overtime_status = False
|
||||
elif all(attendance.linked_overtime_ids.mapped(lambda ot: ot.status == 'approved')):
|
||||
attendance.overtime_status = 'approved'
|
||||
elif all(attendance.linked_overtime_ids.mapped(lambda ot: ot.status == 'refused')):
|
||||
attendance.overtime_status = 'refused'
|
||||
else:
|
||||
attendance.overtime_status = 'to_approve'
|
||||
|
||||
@api.depends('check_in', 'check_out', 'employee_id')
|
||||
def _compute_overtime_hours(self):
|
||||
for attendance in self:
|
||||
attendance.overtime_hours = sum(attendance.linked_overtime_ids.mapped('manual_duration'))
|
||||
|
||||
@api.depends('check_in', 'check_out', 'employee_id')
|
||||
def _compute_validated_overtime_hours(self):
|
||||
for attendance in self:
|
||||
attendance.validated_overtime_hours = sum(attendance.linked_overtime_ids.filtered_domain([('status', '=', 'approved')]).mapped('manual_duration'))
|
||||
|
||||
@api.depends('check_in', 'check_out', 'employee_id')
|
||||
def _compute_linked_overtime_ids(self):
|
||||
overtimes_by_attendance = self._linked_overtimes().grouped(lambda ot: (ot.employee_id, ot.time_start))
|
||||
for attendance in self:
|
||||
attendance.linked_overtime_ids = overtimes_by_attendance.get((attendance.employee_id, attendance.check_in), False)
|
||||
|
||||
@api.depends('employee_id', 'check_in', 'check_out')
|
||||
def _compute_display_name(self):
|
||||
tz = request.httprequest.cookies.get('tz') if request else None
|
||||
for attendance in self:
|
||||
if not attendance.check_out:
|
||||
result.append((attendance.id, _("%(empl_name)s from %(check_in)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'check_in': format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
}))
|
||||
attendance.display_name = _(
|
||||
"From %s",
|
||||
format_time(self.env, attendance.check_in, time_format=None, tz=tz, lang_code=self.env.lang),
|
||||
)
|
||||
else:
|
||||
result.append((attendance.id, _("%(empl_name)s from %(check_in)s to %(check_out)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'check_in': format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
'check_out': format_datetime(self.env, attendance.check_out, dt_format=False),
|
||||
}))
|
||||
return result
|
||||
attendance.display_name = _(
|
||||
"%(worked_hours)s (%(check_in)s-%(check_out)s)",
|
||||
worked_hours=format_duration(attendance.worked_hours),
|
||||
check_in=format_time(self.env, attendance.check_in, time_format=None, tz=tz, lang_code=self.env.lang),
|
||||
check_out=format_time(self.env, attendance.check_out, time_format=None, tz=tz, lang_code=self.env.lang),
|
||||
)
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_is_manager(self):
|
||||
have_manager_right = self.env.user.has_group('hr_attendance.group_hr_attendance_user')
|
||||
have_officer_right = self.env.user.has_group('hr_attendance.group_hr_attendance_officer')
|
||||
for attendance in self:
|
||||
attendance.is_manager = have_manager_right or \
|
||||
(have_officer_right and attendance.attendance_manager_id.id == self.env.user.id)
|
||||
|
||||
def _get_employee_calendar(self):
|
||||
self.ensure_one()
|
||||
return self.employee_id.resource_calendar_id or self.employee_id.company_id.resource_calendar_id
|
||||
|
||||
@api.depends('check_in', 'check_out')
|
||||
def _compute_worked_hours(self):
|
||||
""" Computes the worked hours of the attendance record.
|
||||
The worked hours of resource with flexible calendar is computed as the difference
|
||||
between check_in and check_out, without taking into account the lunch_interval"""
|
||||
for attendance in self:
|
||||
if attendance.check_out and attendance.check_in:
|
||||
delta = attendance.check_out - attendance.check_in
|
||||
attendance.worked_hours = delta.total_seconds() / 3600.0
|
||||
if attendance.check_out and attendance.check_in and attendance.employee_id:
|
||||
attendance.worked_hours = attendance._get_worked_hours_in_range(attendance.check_in, attendance.check_out)
|
||||
else:
|
||||
attendance.worked_hours = False
|
||||
|
||||
def _get_worked_hours_in_range(self, start_dt, end_dt):
|
||||
"""Returns the amount of hours worked because of this attendance during the
|
||||
interval defined by [start_dt, end_dt]
|
||||
|
||||
:param start_dt: datetime starting the interval.
|
||||
:param end_dt: datetime ending the interval.
|
||||
:returns: float, hours worked
|
||||
"""
|
||||
self.ensure_one()
|
||||
calendar = self._get_employee_calendar()
|
||||
resource = self.employee_id.resource_id
|
||||
tz = timezone(resource.tz) if not calendar else timezone(calendar.tz)
|
||||
start_dt_tz = max(self.check_in, start_dt).astimezone(tz)
|
||||
end_dt_tz = min(self.check_out, end_dt).astimezone(tz)
|
||||
|
||||
if end_dt_tz < start_dt_tz:
|
||||
return 0.0
|
||||
|
||||
lunch_intervals = []
|
||||
if not resource._is_flexible():
|
||||
lunch_intervals = self.employee_id._employee_attendance_intervals(start_dt_tz, end_dt_tz, lunch=True)
|
||||
attendance_intervals = Intervals([(start_dt_tz, end_dt_tz, self)]) - lunch_intervals
|
||||
return sum_intervals(attendance_intervals)
|
||||
|
||||
@api.constrains('check_in', 'check_out')
|
||||
def _check_validity_check_in_check_out(self):
|
||||
""" verifies if check_in is earlier than check_out. """
|
||||
|
|
@ -76,10 +217,9 @@ class HrAttendance(models.Model):
|
|||
('id', '!=', attendance.id),
|
||||
], order='check_in desc', limit=1)
|
||||
if last_attendance_before_check_in and last_attendance_before_check_in.check_out and last_attendance_before_check_in.check_out > attendance.check_in:
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee was already checked in on %(datetime)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'datetime': format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
})
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee was already checked in on %(datetime)s",
|
||||
empl_name=attendance.employee_id.name,
|
||||
datetime=format_datetime(self.env, attendance.check_in, dt_format=False)))
|
||||
|
||||
if not attendance.check_out:
|
||||
# if our attendance is "open" (no check_out), we verify there is no other "open" attendance
|
||||
|
|
@ -89,10 +229,9 @@ class HrAttendance(models.Model):
|
|||
('id', '!=', attendance.id),
|
||||
], order='check_in desc', limit=1)
|
||||
if no_check_out_attendances:
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee hasn't checked out since %(datetime)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'datetime': format_datetime(self.env, no_check_out_attendances.check_in, dt_format=False),
|
||||
})
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee hasn't checked out since %(datetime)s",
|
||||
empl_name=attendance.employee_id.name,
|
||||
datetime=format_datetime(self.env, no_check_out_attendances.check_in, dt_format=False)))
|
||||
else:
|
||||
# we verify that the latest attendance with check_in time before our check_out time
|
||||
# is the same as the one before our check_in time computed before, otherwise it overlaps
|
||||
|
|
@ -102,173 +241,77 @@ class HrAttendance(models.Model):
|
|||
('id', '!=', attendance.id),
|
||||
], order='check_in desc', limit=1)
|
||||
if last_attendance_before_check_out and last_attendance_before_check_in != last_attendance_before_check_out:
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee was already checked in on %(datetime)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'datetime': format_datetime(self.env, last_attendance_before_check_out.check_in, dt_format=False),
|
||||
})
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee was already checked in on %(datetime)s",
|
||||
empl_name=attendance.employee_id.name,
|
||||
datetime=format_datetime(self.env, last_attendance_before_check_out.check_in, dt_format=False)))
|
||||
|
||||
@api.model
|
||||
def _get_day_start_and_day(self, employee, dt):
|
||||
#Returns a tuple containing the datetime in naive UTC of the employee's start of the day
|
||||
def _get_day_start_and_day(self, employee, dt): # TODO probably no longer need by the end
|
||||
# Returns a tuple containing the datetime in naive UTC of the employee's start of the day
|
||||
# and the date it was for that employee
|
||||
if not dt.tzinfo:
|
||||
date_employee_tz = pytz.utc.localize(dt).astimezone(pytz.timezone(employee._get_tz()))
|
||||
calendar_tz = employee._get_calendar_tz_batch(dt)[employee.id]
|
||||
date_employee_tz = pytz.utc.localize(dt).astimezone(pytz.timezone(calendar_tz))
|
||||
else:
|
||||
date_employee_tz = dt
|
||||
start_day_employee_tz = date_employee_tz.replace(hour=0, minute=0, second=0)
|
||||
return (start_day_employee_tz.astimezone(pytz.utc).replace(tzinfo=None), start_day_employee_tz.date())
|
||||
|
||||
def _get_attendances_dates(self):
|
||||
# Returns a dictionnary {employee_id: set((datetimes, dates))}
|
||||
attendances_emp = defaultdict(set)
|
||||
for attendance in self.filtered(lambda a: a.employee_id.company_id.hr_attendance_overtime and a.check_in):
|
||||
check_in_day_start = attendance._get_day_start_and_day(attendance.employee_id, attendance.check_in)
|
||||
if check_in_day_start[0] < datetime.combine(attendance.employee_id.company_id.overtime_start_date, datetime.min.time()):
|
||||
continue
|
||||
attendances_emp[attendance.employee_id].add(check_in_day_start)
|
||||
if attendance.check_out:
|
||||
check_out_day_start = attendance._get_day_start_and_day(attendance.employee_id, attendance.check_out)
|
||||
attendances_emp[attendance.employee_id].add(check_out_day_start)
|
||||
return attendances_emp
|
||||
def _get_week_date_range(self):
|
||||
assert self
|
||||
dates = self.mapped('date')
|
||||
date_start, date_end = min(dates), max(dates)
|
||||
date_start = date_start - relativedelta(days=date_start.weekday())
|
||||
date_end = date_end + relativedelta(days=6 - date_end.weekday())
|
||||
return date_start, date_end
|
||||
|
||||
def _get_overtime_leave_domain(self):
|
||||
return []
|
||||
def _get_overtimes_to_update_domain(self):
|
||||
if not self:
|
||||
return Domain.FALSE
|
||||
domain_list = [Domain.AND([
|
||||
Domain('employee_id', '=', employee.id),
|
||||
Domain('date', '<=', max(attendances.mapped('check_out')).date() + relativedelta(SU)),
|
||||
Domain('date', '>=', min(attendances.mapped('check_in')).date() + relativedelta(MO(-1))),
|
||||
]) for employee, attendances in self.filtered(lambda att: att.check_out).grouped('employee_id').items()]
|
||||
if not domain_list:
|
||||
return Domain.FALSE
|
||||
return Domain.OR(domain_list) if len(domain_list) > 1 else domain_list[0]
|
||||
|
||||
def _update_overtime(self, employee_attendance_dates=None):
|
||||
if employee_attendance_dates is None:
|
||||
employee_attendance_dates = self._get_attendances_dates()
|
||||
def _update_overtime(self, attendance_domain=None):
|
||||
if not attendance_domain:
|
||||
attendance_domain = self._get_overtimes_to_update_domain()
|
||||
self.env['hr.attendance.overtime.line'].search(attendance_domain).unlink()
|
||||
all_attendances = (self | self.env['hr.attendance'].search(attendance_domain)).filtered_domain([('check_out', '!=', False)])
|
||||
if not all_attendances:
|
||||
return
|
||||
|
||||
overtime_to_unlink = self.env['hr.attendance.overtime']
|
||||
start_check_in = min(all_attendances.mapped('check_in')).date() - relativedelta(days=1) # for timezone
|
||||
min_check_in = utc.localize(datetime.combine(start_check_in, datetime.min.time()))
|
||||
|
||||
start_check_out = max(all_attendances.mapped('check_out')).date() + relativedelta(days=1)
|
||||
max_check_out = utc.localize(datetime.combine(start_check_out, datetime.max.time())) # for timezone
|
||||
|
||||
version_periods_by_employee = all_attendances.employee_id.sudo()._get_version_periods(min_check_in, max_check_out)
|
||||
attendances_by_employee = all_attendances.grouped('employee_id')
|
||||
attendances_by_ruleset = defaultdict(lambda: self.env['hr.attendance'])
|
||||
for employee, emp_attendance in attendances_by_employee.items():
|
||||
for attendance in emp_attendance:
|
||||
version_sudo = employee.sudo()._get_version(attendance._get_localized_times()[0])
|
||||
ruleset_sudo = version_sudo.ruleset_id
|
||||
if ruleset_sudo:
|
||||
attendances_by_ruleset[ruleset_sudo] += attendance
|
||||
employees = all_attendances.employee_id
|
||||
schedules_intervals_by_employee = employees._get_schedules_by_employee_by_work_type(min_check_in, max_check_out, version_periods_by_employee)
|
||||
overtime_vals_list = []
|
||||
|
||||
for emp, attendance_dates in employee_attendance_dates.items():
|
||||
# get_attendances_dates returns the date translated from the local timezone without tzinfo,
|
||||
# and contains all the date which we need to check for overtime
|
||||
attendance_domain = []
|
||||
for attendance_date in attendance_dates:
|
||||
attendance_domain = OR([attendance_domain, [
|
||||
('check_in', '>=', attendance_date[0]), ('check_in', '<', attendance_date[0] + timedelta(hours=24)),
|
||||
]])
|
||||
attendance_domain = AND([[('employee_id', '=', emp.id)], attendance_domain])
|
||||
|
||||
# Attendances per LOCAL day
|
||||
attendances_per_day = defaultdict(lambda: self.env['hr.attendance'])
|
||||
all_attendances = self.env['hr.attendance'].search(attendance_domain)
|
||||
for attendance in all_attendances:
|
||||
check_in_day_start = attendance._get_day_start_and_day(attendance.employee_id, attendance.check_in)
|
||||
attendances_per_day[check_in_day_start[1]] += attendance
|
||||
|
||||
# As _attendance_intervals_batch and _leave_intervals_batch both take localized dates we need to localize those date
|
||||
start = pytz.utc.localize(min(attendance_dates, key=itemgetter(0))[0])
|
||||
stop = pytz.utc.localize(max(attendance_dates, key=itemgetter(0))[0] + timedelta(hours=24))
|
||||
|
||||
# Retrieve expected attendance intervals
|
||||
expected_attendances = emp._get_expected_attendances(start, stop, domain=AND([self._get_overtime_leave_domain(), [('company_id', 'in', [False, emp.company_id.id])]]))
|
||||
|
||||
# working_times = {date: [(start, stop)]}
|
||||
working_times = defaultdict(lambda: [])
|
||||
for expected_attendance in expected_attendances:
|
||||
# Exclude resource.calendar.attendance
|
||||
working_times[expected_attendance[0].date()].append(expected_attendance[:2])
|
||||
|
||||
overtimes = self.env['hr.attendance.overtime'].sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('date', 'in', [day_data[1] for day_data in attendance_dates]),
|
||||
('adjustment', '=', False),
|
||||
])
|
||||
|
||||
company_threshold = emp.company_id.overtime_company_threshold / 60.0
|
||||
employee_threshold = emp.company_id.overtime_employee_threshold / 60.0
|
||||
|
||||
for day_data in attendance_dates:
|
||||
attendance_date = day_data[1]
|
||||
attendances = attendances_per_day.get(attendance_date, self.browse())
|
||||
unfinished_shifts = attendances.filtered(lambda a: not a.check_out)
|
||||
overtime_duration = 0
|
||||
overtime_duration_real = 0
|
||||
# Overtime is not counted if any shift is not closed or if there are no attendances for that day,
|
||||
# this could happen when deleting attendances.
|
||||
if not unfinished_shifts and attendances:
|
||||
# The employee usually doesn't work on that day
|
||||
if not working_times[attendance_date]:
|
||||
# User does not have any resource_calendar_attendance for that day (week-end for example)
|
||||
overtime_duration = sum(attendances.mapped('worked_hours'))
|
||||
overtime_duration_real = overtime_duration
|
||||
# The employee usually work on that day
|
||||
else:
|
||||
# Compute start and end time for that day
|
||||
planned_start_dt, planned_end_dt = False, False
|
||||
planned_work_duration = 0
|
||||
for calendar_attendance in working_times[attendance_date]:
|
||||
planned_start_dt = min(planned_start_dt, calendar_attendance[0]) if planned_start_dt else calendar_attendance[0]
|
||||
planned_end_dt = max(planned_end_dt, calendar_attendance[1]) if planned_end_dt else calendar_attendance[1]
|
||||
planned_work_duration += (calendar_attendance[1] - calendar_attendance[0]).total_seconds() / 3600.0
|
||||
# Count time before, during and after 'working hours'
|
||||
pre_work_time, work_duration, post_work_time = 0, 0, 0
|
||||
|
||||
for attendance in attendances:
|
||||
# consider check_in as planned_start_dt if within threshold
|
||||
# if delta_in < 0: Checked in after supposed start of the day
|
||||
# if delta_in > 0: Checked in before supposed start of the day
|
||||
local_check_in = pytz.utc.localize(attendance.check_in)
|
||||
delta_in = (planned_start_dt - local_check_in).total_seconds() / 3600.0
|
||||
|
||||
# Started before or after planned date within the threshold interval
|
||||
if (delta_in > 0 and delta_in <= company_threshold) or\
|
||||
(delta_in < 0 and abs(delta_in) <= employee_threshold):
|
||||
local_check_in = planned_start_dt
|
||||
local_check_out = pytz.utc.localize(attendance.check_out)
|
||||
|
||||
# same for check_out as planned_end_dt
|
||||
delta_out = (local_check_out - planned_end_dt).total_seconds() / 3600.0
|
||||
# if delta_out < 0: Checked out before supposed start of the day
|
||||
# if delta_out > 0: Checked out after supposed start of the day
|
||||
|
||||
# Finised before or after planned date within the threshold interval
|
||||
if (delta_out > 0 and delta_out <= company_threshold) or\
|
||||
(delta_out < 0 and abs(delta_out) <= employee_threshold):
|
||||
local_check_out = planned_end_dt
|
||||
|
||||
# There is an overtime at the start of the day
|
||||
if local_check_in < planned_start_dt:
|
||||
pre_work_time += (min(planned_start_dt, local_check_out) - local_check_in).total_seconds() / 3600.0
|
||||
# Interval inside the working hours -> Considered as working time
|
||||
if local_check_in <= planned_end_dt and local_check_out >= planned_start_dt:
|
||||
work_duration += (min(planned_end_dt, local_check_out) - max(planned_start_dt, local_check_in)).total_seconds() / 3600.0
|
||||
# There is an overtime at the end of the day
|
||||
if local_check_out > planned_end_dt:
|
||||
post_work_time += (local_check_out - max(planned_end_dt, local_check_in)).total_seconds() / 3600.0
|
||||
|
||||
# Overtime within the planned work hours + overtime before/after work hours is > company threshold
|
||||
overtime_duration = work_duration - planned_work_duration
|
||||
if pre_work_time > company_threshold:
|
||||
overtime_duration += pre_work_time
|
||||
if post_work_time > company_threshold:
|
||||
overtime_duration += post_work_time
|
||||
# Global overtime including the thresholds
|
||||
overtime_duration_real = sum(attendances.mapped('worked_hours')) - planned_work_duration
|
||||
|
||||
overtime = overtimes.filtered(lambda o: o.date == attendance_date)
|
||||
if not float_is_zero(overtime_duration, 2) or unfinished_shifts:
|
||||
# Do not create if any attendance doesn't have a check_out, update if exists
|
||||
if unfinished_shifts:
|
||||
overtime_duration = 0
|
||||
if not overtime and overtime_duration:
|
||||
overtime_vals_list.append({
|
||||
'employee_id': emp.id,
|
||||
'date': attendance_date,
|
||||
'duration': overtime_duration,
|
||||
'duration_real': overtime_duration_real,
|
||||
})
|
||||
elif overtime:
|
||||
overtime.sudo().write({
|
||||
'duration': overtime_duration,
|
||||
'duration_real': overtime_duration
|
||||
})
|
||||
elif overtime:
|
||||
overtime_to_unlink |= overtime
|
||||
self.env['hr.attendance.overtime'].sudo().create(overtime_vals_list)
|
||||
overtime_to_unlink.sudo().unlink()
|
||||
for ruleset_sudo, ruleset_attendances in attendances_by_ruleset.items():
|
||||
attendances_dates = list(chain(*ruleset_attendances._get_dates().values()))
|
||||
overtime_vals_list.extend(
|
||||
ruleset_sudo.rule_ids._generate_overtime_vals_v2(min(attendances_dates), max(attendances_dates), ruleset_attendances, schedules_intervals_by_employee)
|
||||
)
|
||||
self.env['hr.attendance.overtime.line'].create(overtime_vals_list)
|
||||
self.env.add_to_compute(self._fields['overtime_hours'], all_attendances)
|
||||
self.env.add_to_compute(self._fields['validated_overtime_hours'], all_attendances)
|
||||
self.env.add_to_compute(self._fields['overtime_status'], all_attendances)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
|
|
@ -279,23 +322,360 @@ class HrAttendance(models.Model):
|
|||
def write(self, vals):
|
||||
if vals.get('employee_id') and \
|
||||
vals['employee_id'] not in self.env.user.employee_ids.ids and \
|
||||
not self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
raise AccessError(_("Do not have access, user cannot edit the attendances that are not his own."))
|
||||
attendances_dates = self._get_attendances_dates()
|
||||
result = super(HrAttendance, self).write(vals)
|
||||
not self.env.user.has_group('hr_attendance.group_hr_attendance_manager') and \
|
||||
self.env['hr.employee'].sudo().browse(vals['employee_id']).attendance_manager_id.id != self.env.user.id:
|
||||
raise AccessError(_("Do not have access, user cannot edit the attendances that are not their own or if they are not the attendance manager of the employee."))
|
||||
domain_pre = self._get_overtimes_to_update_domain()
|
||||
result = super().write(vals)
|
||||
if any(field in vals for field in ['employee_id', 'check_in', 'check_out']):
|
||||
# Merge attendance dates before and after write to recompute the
|
||||
# overtime if the attendances have been moved to another day
|
||||
for emp, dates in self._get_attendances_dates().items():
|
||||
attendances_dates[emp] |= dates
|
||||
self._update_overtime(attendances_dates)
|
||||
domain_post = self._get_overtimes_to_update_domain()
|
||||
self._update_overtime(Domain.OR([domain_pre, domain_post]))
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
attendances_dates = self._get_attendances_dates()
|
||||
super(HrAttendance, self).unlink()
|
||||
self._update_overtime(attendances_dates)
|
||||
domain = self._get_overtimes_to_update_domain()
|
||||
res = super().unlink()
|
||||
self.exists()._update_overtime(domain)
|
||||
return res
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self):
|
||||
def copy(self, default=None):
|
||||
raise exceptions.UserError(_('You cannot duplicate an attendance.'))
|
||||
|
||||
def action_in_attendance_maps(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': get_google_maps_url(self.in_latitude, self.in_longitude),
|
||||
'target': 'new'
|
||||
}
|
||||
|
||||
def action_out_attendance_maps(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': get_google_maps_url(self.out_latitude, self.out_longitude),
|
||||
'target': 'new'
|
||||
}
|
||||
|
||||
def get_kiosk_url(self):
|
||||
return self.get_base_url() + "/hr_attendance/" + self.env.company.attendance_kiosk_key
|
||||
|
||||
@api.model
|
||||
def has_demo_data(self):
|
||||
if not self.env.user.has_group("hr_attendance.group_hr_attendance_user"):
|
||||
return True
|
||||
# This record only exists if the scenario has been already launched
|
||||
demo_tag = self.env.ref('hr_attendance.resource_calendar_std_38h', raise_if_not_found=False)
|
||||
return bool(demo_tag) or bool(self.env['ir.module.module'].search_count([('demo', '=', True)]))
|
||||
|
||||
def _load_demo_data(self):
|
||||
if self.has_demo_data():
|
||||
return
|
||||
env_sudo = self.sudo().with_context({}).env
|
||||
env_sudo['hr.employee']._load_scenario()
|
||||
# Load employees, schedules, departments and partners
|
||||
convert.convert_file(env_sudo, 'hr_attendance', 'data/scenarios/hr_attendance_scenario.xml', None, mode='init')
|
||||
|
||||
employee_sj = self.env.ref('hr.employee_sj')
|
||||
employee_mw = self.env.ref('hr.employee_mw')
|
||||
employee_eg = self.env.ref('hr.employee_eg')
|
||||
|
||||
# Retrieve employee from xml file
|
||||
# Calculate attendances records for the previous month and the current until today
|
||||
now = datetime.now()
|
||||
previous_month_datetime = (now - relativedelta(months=1))
|
||||
date_range = now.day + monthrange(previous_month_datetime.year, previous_month_datetime.month)[1]
|
||||
city_coordinates = (50.27, 5.31)
|
||||
city_coordinates_exception = (51.01, 2.82)
|
||||
city_dict = {
|
||||
'latitude': city_coordinates_exception[0],
|
||||
'longitude': city_coordinates_exception[1],
|
||||
'city': 'Rellemstraat'
|
||||
}
|
||||
city_exception_dict = {
|
||||
'latitude': city_coordinates[0],
|
||||
'longitude': city_coordinates[1],
|
||||
'city': 'Waillet'
|
||||
}
|
||||
attendance_values = []
|
||||
for i in range(1, date_range):
|
||||
check_in_date = now.replace(hour=6, minute=0, second=randint(0, 59)) + timedelta(days=-i, minutes=randint(-2, 3))
|
||||
if check_in_date.weekday() not in range(0, 5):
|
||||
continue
|
||||
check_out_date = now.replace(hour=10, minute=0, second=randint(0, 59)) + timedelta(days=-i, minutes=randint(-2, -1))
|
||||
check_in_date_after_lunch = now.replace(hour=11, minute=0, second=randint(0, 59)) + timedelta(days=-i, minutes=randint(-2, -1))
|
||||
check_out_date_after_lunch = now.replace(hour=15, minute=0, second=randint(0, 59)) + timedelta(days=-i, minutes=randint(1, 3))
|
||||
|
||||
# employee_eg doesn't work on friday
|
||||
eg_data = []
|
||||
if check_in_date.weekday() != 4:
|
||||
# employee_eg will compensate her work's hours between weeks.
|
||||
if check_in_date.isocalendar().week % 2:
|
||||
employee_eg_hours = {
|
||||
'check_in_date': check_in_date + timedelta(hours=1),
|
||||
'check_out_date': check_out_date,
|
||||
'check_in_date_after_lunch': check_in_date_after_lunch,
|
||||
'check_out_date_after_lunch': check_out_date_after_lunch + timedelta(hours=-1),
|
||||
}
|
||||
else:
|
||||
employee_eg_hours = {
|
||||
'check_in_date': check_in_date,
|
||||
'check_out_date': check_out_date,
|
||||
'check_in_date_after_lunch': check_in_date_after_lunch,
|
||||
'check_out_date_after_lunch': check_out_date_after_lunch + timedelta(hours=1, minutes=30),
|
||||
}
|
||||
eg_data = [{
|
||||
'employee_id': employee_eg.id,
|
||||
'check_in': employee_eg_hours['check_in_date'],
|
||||
'check_out': employee_eg_hours['check_out_date'],
|
||||
'in_mode': "kiosk",
|
||||
'out_mode': "kiosk"
|
||||
}, {
|
||||
'employee_id': employee_eg.id,
|
||||
'check_in': employee_eg_hours['check_in_date_after_lunch'],
|
||||
'check_out': employee_eg_hours['check_out_date_after_lunch'],
|
||||
'in_mode': "kiosk",
|
||||
'out_mode': "kiosk",
|
||||
}]
|
||||
|
||||
# calculate GPS coordination for employee_mw (systray attendance)
|
||||
if randint(1, 10) == 1:
|
||||
city_data = city_exception_dict
|
||||
else:
|
||||
city_data = city_dict
|
||||
mw_data = [{
|
||||
'employee_id': employee_mw.id,
|
||||
'check_in': check_in_date,
|
||||
'check_out': check_out_date,
|
||||
'in_mode': "systray",
|
||||
'out_mode': "systray",
|
||||
'in_longitude': city_data['longitude'],
|
||||
'out_longitude': city_data['longitude'],
|
||||
'in_latitude': city_data['latitude'],
|
||||
'out_latitude': city_data['latitude'],
|
||||
'in_location': city_data['city'],
|
||||
'out_location': city_data['city'],
|
||||
'in_ip_address': "127.0.0.1",
|
||||
'out_ip_address': "127.0.0.1",
|
||||
'in_browser': 'chrome',
|
||||
'out_browser': 'chrome'
|
||||
}, {
|
||||
'employee_id': employee_mw.id,
|
||||
'check_in': check_in_date_after_lunch,
|
||||
'check_out': check_out_date_after_lunch,
|
||||
'in_mode': "systray",
|
||||
'out_mode': "systray",
|
||||
'in_longitude': city_data['longitude'],
|
||||
'out_longitude': city_data['longitude'],
|
||||
'in_latitude': city_data['latitude'],
|
||||
'out_latitude': city_data['latitude'],
|
||||
'in_location': city_data['city'],
|
||||
'out_location': city_data['city'],
|
||||
'in_ip_address': "127.0.0.1",
|
||||
'out_ip_address': "127.0.0.1",
|
||||
'in_browser': 'chrome',
|
||||
'out_browser': 'chrome'
|
||||
}]
|
||||
sj_data = [{
|
||||
'employee_id': employee_sj.id,
|
||||
'check_in': check_in_date + timedelta(minutes=randint(-10, -5)),
|
||||
'check_out': check_out_date,
|
||||
'in_mode': "manual",
|
||||
'out_mode': "manual"
|
||||
}, {
|
||||
'employee_id': employee_sj.id,
|
||||
'check_in': check_in_date_after_lunch,
|
||||
'check_out': check_out_date_after_lunch + timedelta(hours=1, minutes=randint(-20, 10)),
|
||||
'in_mode': "manual",
|
||||
'out_mode': "manual"
|
||||
}]
|
||||
attendance_values.extend(eg_data + mw_data + sj_data)
|
||||
self.env['hr.attendance'].create(attendance_values)
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'reload',
|
||||
}
|
||||
|
||||
def action_try_kiosk(self):
|
||||
if not self.env.user.has_group("hr_attendance.group_hr_attendance_user"):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': _("You don't have the rights to execute that action."),
|
||||
'type': 'info',
|
||||
}
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'target': 'self',
|
||||
'url': self.env.company.attendance_kiosk_url + '?from_trial_mode=True'
|
||||
}
|
||||
|
||||
def _read_group_employee_id(self, resources, domain):
|
||||
user_domain = Domain(self.env.context.get('user_domain') or Domain.TRUE)
|
||||
employee_domain = Domain('company_id', 'in', self.env.context.get('allowed_company_ids', []))
|
||||
if not self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
employee_domain &= Domain('attendance_manager_id', '=', self.env.user.id)
|
||||
if user_domain.is_true():
|
||||
# Workaround to make it work only for list view.
|
||||
if 'gantt_start_date' in self.env.context:
|
||||
return self.env['hr.employee'].search(employee_domain)
|
||||
return resources & self.env['hr.employee'].search(employee_domain)
|
||||
else:
|
||||
employee_name_domain = Domain.OR(
|
||||
Domain('name', condition.operator, condition.value)
|
||||
for condition in user_domain.iter_conditions()
|
||||
if condition.field_expr == 'employee_id'
|
||||
)
|
||||
return resources | self.env['hr.employee'].search(employee_name_domain & employee_domain)
|
||||
|
||||
def _linked_overtimes(self):
|
||||
return self.env['hr.attendance.overtime.line'].search([
|
||||
('time_start', 'in', self.mapped('check_in')),
|
||||
('employee_id', 'in', self.employee_id.ids),
|
||||
])
|
||||
|
||||
def action_approve_overtime(self):
|
||||
self.linked_overtime_ids.action_approve()
|
||||
|
||||
def action_refuse_overtime(self):
|
||||
self.linked_overtime_ids.action_refuse()
|
||||
|
||||
def _cron_auto_check_out(self):
|
||||
def check_in_tz(attendance):
|
||||
"""Returns check-in time in calendar's timezone."""
|
||||
return attendance.check_in.astimezone(pytz.timezone(attendance.employee_id._get_tz()))
|
||||
|
||||
to_verify = self.env['hr.attendance'].search(
|
||||
[('check_out', '=', False),
|
||||
('employee_id.company_id.auto_check_out', '=', True),
|
||||
('employee_id.resource_calendar_id.flexible_hours', '=', False)]
|
||||
)
|
||||
|
||||
if not to_verify:
|
||||
return
|
||||
|
||||
to_verify_min_date = min(to_verify.mapped('check_in')).replace(hour=0, minute=0, second=0)
|
||||
previous_attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', 'in', to_verify.mapped('employee_id').ids),
|
||||
('check_in', '>', to_verify_min_date),
|
||||
('check_out', '!=', False)
|
||||
])
|
||||
|
||||
mapped_previous_duration = defaultdict(lambda: defaultdict(float))
|
||||
for previous in previous_attendances:
|
||||
mapped_previous_duration[previous.employee_id][check_in_tz(previous).date()] += previous.worked_hours
|
||||
|
||||
all_companies = to_verify.employee_id.company_id
|
||||
|
||||
for company in all_companies:
|
||||
max_tol = company.auto_check_out_tolerance
|
||||
to_verify_company = to_verify.filtered(lambda a: a.employee_id.company_id.id == company.id)
|
||||
|
||||
for att in to_verify_company:
|
||||
|
||||
employee_timezone = pytz.timezone(att.employee_id._get_tz())
|
||||
check_in_datetime = check_in_tz(att)
|
||||
now_datetime = fields.Datetime.now().astimezone(employee_timezone)
|
||||
current_attendance_duration = (now_datetime - check_in_datetime).total_seconds() / 3600
|
||||
previous_attendances_duration = mapped_previous_duration[att.employee_id][check_in_datetime.date()]
|
||||
|
||||
expected_worked_hours = sum(
|
||||
att.employee_id.resource_calendar_id.attendance_ids.filtered(
|
||||
lambda a: a.dayofweek == str(check_in_datetime.weekday())
|
||||
and (not a.two_weeks_calendar or a.week_type == str(a.get_week_type(check_in_datetime.date())))
|
||||
).mapped("duration_hours")
|
||||
)
|
||||
|
||||
# Attendances where Last open attendance time + previously worked time on that day + tolerance greater than the attendances hours (including lunch) in his calendar
|
||||
if (current_attendance_duration + previous_attendances_duration - max_tol) > expected_worked_hours:
|
||||
att.check_out = att.check_in.replace(hour=23, minute=59, second=59)
|
||||
excess_hours = att.worked_hours - (expected_worked_hours + max_tol - previous_attendances_duration)
|
||||
att.write({
|
||||
"check_out": max(att.check_out - relativedelta(hours=excess_hours), att.check_in + relativedelta(seconds=1)),
|
||||
"out_mode": "auto_check_out"
|
||||
})
|
||||
att.message_post(
|
||||
body=_('This attendance was automatically checked out because the employee exceeded the allowed time for their scheduled work hours.')
|
||||
)
|
||||
|
||||
def _cron_absence_detection(self):
|
||||
"""
|
||||
Objective is to create technical attendances on absence days to have negative overtime created for that day
|
||||
"""
|
||||
yesterday = datetime.today().replace(hour=0, minute=0, second=0) - relativedelta(days=1)
|
||||
companies = self.env['res.company'].search([('absence_management', '=', True)])
|
||||
if not companies:
|
||||
return
|
||||
|
||||
checked_in_employees = self.env['hr.attendance.overtime.line'].search([('date', '=', yesterday)]).employee_id
|
||||
|
||||
technical_attendances_vals = []
|
||||
absent_employees = self.env['hr.employee'].search([
|
||||
('id', 'not in', checked_in_employees.ids),
|
||||
('company_id', 'in', companies.ids),
|
||||
('resource_calendar_id.flexible_hours', '=', False),
|
||||
('current_version_id.contract_date_start', '<=', fields.Date.today() - relativedelta(days=1))
|
||||
])
|
||||
|
||||
for emp in absent_employees:
|
||||
local_day_start = pytz.utc.localize(yesterday).astimezone(pytz.timezone(emp._get_tz()))
|
||||
technical_attendances_vals.append({
|
||||
'check_in': local_day_start.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'check_out': (local_day_start + relativedelta(seconds=1)).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'in_mode': 'technical',
|
||||
'out_mode': 'technical',
|
||||
'employee_id': emp.id
|
||||
})
|
||||
|
||||
technical_attendances = self.env['hr.attendance'].create(technical_attendances_vals)
|
||||
to_unlink = technical_attendances.filtered(lambda a: a.overtime_hours == 0)
|
||||
|
||||
body = _('This attendance was automatically created to cover an unjustified absence on that day.')
|
||||
for technical_attendance in technical_attendances - to_unlink:
|
||||
technical_attendance.message_post(body=body)
|
||||
|
||||
to_unlink.unlink()
|
||||
|
||||
def _get_localized_times(self):
|
||||
self.ensure_one()
|
||||
tz = timezone(self.employee_id.sudo()._get_version(self.check_in.date()).tz)
|
||||
localized_start = utc.localize(self.check_in).astimezone(tz).replace(tzinfo=None)
|
||||
localized_end = utc.localize(self.check_out).astimezone(tz).replace(tzinfo=None)
|
||||
return localized_start, localized_end
|
||||
|
||||
def _get_dates(self):
|
||||
result = {}
|
||||
for attendance in self:
|
||||
localized_start, localized_end = attendance._get_localized_times()
|
||||
result[attendance] = list(rrule(DAILY, dtstart=localized_start, until=localized_end))
|
||||
return result
|
||||
|
||||
def _get_attendance_by_periods_by_employee(self):
|
||||
attendance_by_employee_by_day = defaultdict(lambda: defaultdict(lambda: Intervals([], keep_distinct=True)))
|
||||
attendance_by_employee_by_week = defaultdict(lambda: defaultdict(lambda: Intervals([], keep_distinct=True)))
|
||||
|
||||
for attendance in self.sorted('check_in'):
|
||||
employee = attendance.employee_id
|
||||
check_in, check_out = attendance._get_localized_times()
|
||||
for day in rrule(dtstart=check_in.date(), until=check_out.date(), freq=DAILY):
|
||||
week_date = day + relativedelta(days=6 - day.weekday())
|
||||
|
||||
start_datetime = datetime.combine(day, time.min)
|
||||
stop_datetime_for_day = datetime.combine(day, time.max)
|
||||
day_interval = Intervals([(start_datetime, stop_datetime_for_day, self.env['resource.calendar'])])
|
||||
|
||||
stop_datetime_for_week = datetime.combine(week_date, time.max)
|
||||
week_interval = Intervals([(start_datetime, stop_datetime_for_week, self.env['resource.calendar'])])
|
||||
|
||||
attendance_interval = Intervals([(check_in, check_out, attendance)])
|
||||
attendance_by_employee_by_day[employee][day] |= attendance_interval & day_interval
|
||||
attendance_by_employee_by_week[employee][week_date] |= attendance_interval & week_interval
|
||||
|
||||
return {
|
||||
'day': attendance_by_employee_by_day,
|
||||
'week': attendance_by_employee_by_week
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue