mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-26 06:52:00 +02:00
19.0 vanilla
This commit is contained in:
parent
a1137a1456
commit
e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions
|
|
@ -1,10 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_config_settings
|
||||
from . import hr_attendance
|
||||
from . import hr_attendance_overtime
|
||||
from . import hr_attendance_overtime_rule
|
||||
from . import hr_attendance_overtime_ruleset
|
||||
from . import hr_employee
|
||||
from . import hr_employee_public
|
||||
from . import ir_ui_menu
|
||||
from . import hr_version
|
||||
from . import ir_http
|
||||
from . import res_company
|
||||
from . import res_users
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,108 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class HrAttendanceOvertime(models.Model):
|
||||
_name = "hr.attendance.overtime"
|
||||
_description = "Attendance Overtime"
|
||||
class HrAttendanceOvertimeLine(models.Model):
|
||||
_name = 'hr.attendance.overtime.line'
|
||||
_description = "Attendance Overtime Line"
|
||||
_rec_name = 'employee_id'
|
||||
_order = 'date desc'
|
||||
|
||||
def _default_employee(self):
|
||||
return self.env.user.employee_id
|
||||
_order = 'time_start'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string="Employee", default=_default_employee,
|
||||
'hr.employee', string="Employee",
|
||||
required=True, ondelete='cascade', index=True)
|
||||
company_id = fields.Many2one(related='employee_id.company_id')
|
||||
|
||||
date = fields.Date(string='Day')
|
||||
date = fields.Date(string='Day', index=True, required=True)
|
||||
status = fields.Selection([
|
||||
('to_approve', "To Approve"),
|
||||
('approved', "Approved"),
|
||||
('refused', "Refused")
|
||||
],
|
||||
compute='_compute_status',
|
||||
required=True, store=True, readonly=False, precompute=True,
|
||||
)
|
||||
duration = fields.Float(string='Extra Hours', default=0.0, required=True)
|
||||
duration_real = fields.Float(
|
||||
string='Extra Hours (Real)', default=0.0,
|
||||
help="Extra-hours including the threshold duration")
|
||||
adjustment = fields.Boolean(default=False)
|
||||
manual_duration = fields.Float( # TODO -> real_duration for easier upgrade
|
||||
string='Extra Hours (encoded)',
|
||||
compute='_compute_manual_duration',
|
||||
store=True, readonly=False,
|
||||
)
|
||||
|
||||
def init(self):
|
||||
# Allows only 1 overtime record per employee per day unless it's an adjustment
|
||||
self.env.cr.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS hr_attendance_overtime_unique_employee_per_day
|
||||
ON %s (employee_id, date)
|
||||
WHERE adjustment is false""" % (self._table))
|
||||
time_start = fields.Datetime(string='Start') # time_start will be equal to attendance.check_in
|
||||
time_stop = fields.Datetime(string='Stop') # time_stop will be equal to attendance.check_out
|
||||
amount_rate = fields.Float("Overtime pay rate", required=True, default=1.0)
|
||||
|
||||
is_manager = fields.Boolean(compute="_compute_is_manager")
|
||||
|
||||
rule_ids = fields.Many2many("hr.attendance.overtime.rule", string="Applied Rules")
|
||||
|
||||
# in payroll: rate, work_entry_type
|
||||
# in time_off: convertible_to_time_off
|
||||
|
||||
# Check no overlapping overtimes for the same employee.
|
||||
# Technical explanation: Exclude constraints compares the given expression on rows 2 by 2 using the given operator; && on tsrange is the intersection.
|
||||
# cf: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION
|
||||
# for employee_id we compare [employee_id -> employee_id] ranges bc raw integer is not supported (?)
|
||||
# _overtime_no_overlap_same_employee = models.Constraint("""
|
||||
# EXCLUDE USING GIST (
|
||||
# tsrange(time_start, time_stop, '()') WITH &&,
|
||||
# int4range(employee_id, employee_id, '[]') WITH =
|
||||
# )
|
||||
# """,
|
||||
# "Employee cannot have overlapping overtimes",
|
||||
# )
|
||||
_overtime_start_before_end = models.Constraint(
|
||||
'CHECK (time_stop > time_start)',
|
||||
'Starting time should be before end time.',
|
||||
)
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_status(self):
|
||||
for overtime in self:
|
||||
if not overtime.status:
|
||||
overtime.status = 'to_approve' if overtime.employee_id.company_id.attendance_overtime_validation == 'by_manager' else 'approved'
|
||||
|
||||
@api.depends('duration')
|
||||
def _compute_manual_duration(self):
|
||||
for overtime in self:
|
||||
overtime.manual_duration = overtime.duration
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_is_manager(self):
|
||||
has_manager_right = self.env.user.has_group('hr_attendance.group_hr_attendance_manager')
|
||||
has_officer_right = self.env.user.has_group('hr_attendance.group_hr_attendance_officer')
|
||||
for overtime in self:
|
||||
overtime.is_manager = (
|
||||
has_manager_right or
|
||||
(
|
||||
has_officer_right
|
||||
and overtime.employee_id.attendance_manager_id == self.env.user
|
||||
)
|
||||
)
|
||||
|
||||
def action_approve(self):
|
||||
self.write({'status': 'approved'})
|
||||
|
||||
def action_refuse(self):
|
||||
self.write({'status': 'refused'})
|
||||
|
||||
def _linked_attendances(self):
|
||||
return self.env['hr.attendance'].search([
|
||||
('check_in', 'in', self.mapped('time_start')),
|
||||
('employee_id', 'in', self.employee_id.ids),
|
||||
])
|
||||
|
||||
def write(self, vals):
|
||||
if any(key in vals for key in ['status', 'manual_duration']):
|
||||
attendances = self._linked_attendances()
|
||||
self.env.add_to_compute(
|
||||
attendances._fields['overtime_status'],
|
||||
attendances
|
||||
)
|
||||
self.env.add_to_compute(
|
||||
attendances._fields['validated_overtime_hours'],
|
||||
attendances
|
||||
)
|
||||
return super().write(vals)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,756 @@
|
|||
# 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 dateutil.rrule import rrule, DAILY
|
||||
from pytz import timezone, utc
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.date_utils import sum_intervals, float_to_time
|
||||
from odoo.tools.float_utils import float_compare
|
||||
from odoo.tools.intervals import _boundaries, Intervals, invert_intervals
|
||||
|
||||
|
||||
def _naive_utc(dt):
|
||||
return dt.astimezone(utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _midnight(date):
|
||||
return datetime.combine(date, datetime.min.time())
|
||||
|
||||
|
||||
def _replace_interval_records(intervals, records):
|
||||
return Intervals((start, end, records) for (start, end, _) in intervals)
|
||||
|
||||
|
||||
def _record_overlap_intervals(intervals):
|
||||
boundaries = sorted(_boundaries(intervals, 'start', 'stop'))
|
||||
counts = {}
|
||||
interval_vals = []
|
||||
ids = set()
|
||||
start = None
|
||||
for (time, flag, records) in boundaries:
|
||||
for record in records:
|
||||
if (
|
||||
new_count := counts.get(record.id, 0) + {'start': 1, 'stop': -1}[flag]
|
||||
):
|
||||
counts[record.id] = new_count
|
||||
else:
|
||||
del counts[record.id]
|
||||
new_ids = set(counts.keys())
|
||||
if ids != new_ids:
|
||||
if ids and start is not None:
|
||||
interval_vals.append((start, time, records.browse(ids)))
|
||||
if new_ids:
|
||||
start = time
|
||||
ids = new_ids
|
||||
return Intervals(interval_vals, keep_distinct=True)
|
||||
|
||||
|
||||
def _time_delta_hours(td):
|
||||
return td.total_seconds() / 3600
|
||||
|
||||
|
||||
def _last_hours_as_intervals(starting_intervals, hours):
|
||||
last_hours_intervals = []
|
||||
for (start, stop, record) in reversed(starting_intervals):
|
||||
duration = _time_delta_hours(stop - start)
|
||||
if hours >= duration:
|
||||
last_hours_intervals.append((start, stop, record))
|
||||
hours -= duration
|
||||
elif hours > 0:
|
||||
last_hours_intervals.append((stop - relativedelta(hours=hours), stop, record))
|
||||
break
|
||||
else:
|
||||
break
|
||||
return Intervals(last_hours_intervals)
|
||||
|
||||
|
||||
class HrAttendanceOvertimeRule(models.Model):
|
||||
_name = 'hr.attendance.overtime.rule'
|
||||
_description = "Overtime Rule"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
description = fields.Html()
|
||||
base_off = fields.Selection([
|
||||
('quantity', "Quantity"),
|
||||
('timing', "Timing"),
|
||||
],
|
||||
string="Based Off",
|
||||
required=True,
|
||||
default='quantity',
|
||||
help=(
|
||||
"Base for overtime calculation.\n"
|
||||
"Use 'Quantity' when overtime hours are those in excess of a certain amount per day/week.\n"
|
||||
"Use 'Timing' when overtime hours happen on specific days or at specific times"
|
||||
),
|
||||
)
|
||||
|
||||
timing_type = fields.Selection([
|
||||
('work_days', "On any working day"),
|
||||
('non_work_days', "On any non-working day"),
|
||||
('leave', "When employee is off"),
|
||||
('schedule', "Outside of a specific schedule"),
|
||||
# ('employee', "Outside the employee's working schedule"),
|
||||
# ('off_time', "When employee is off"), # TODO in ..._holidays
|
||||
# ('public_leave', "On a holiday"), ......
|
||||
], default='work_days')
|
||||
timing_start = fields.Float("From", default=0)
|
||||
timing_stop = fields.Float("To", default=24)
|
||||
expected_hours_from_contract = fields.Boolean(
|
||||
"Hours from employee schedule",
|
||||
default=True,
|
||||
help="The attendance can go into negative extra hours to represent the missing hours compared to what is expected if the Absence Management setting is enabled.",
|
||||
)
|
||||
|
||||
resource_calendar_id = fields.Many2one(
|
||||
'resource.calendar',
|
||||
string="Schedule",
|
||||
domain=[('flexible_hours', '=', False)],
|
||||
)
|
||||
|
||||
expected_hours = fields.Float(string="Usual work hours")
|
||||
quantity_period = fields.Selection([
|
||||
('day', 'Day'),
|
||||
('week', 'Week')
|
||||
],
|
||||
default='day',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
ruleset_id = fields.Many2one('hr.attendance.overtime.ruleset', required=True, index=True)
|
||||
company_id = fields.Many2one(related='ruleset_id.company_id')
|
||||
|
||||
paid = fields.Boolean("Pay Extra Hours")
|
||||
amount_rate = fields.Float("Rate", default=1.0)
|
||||
|
||||
employee_tolerance = fields.Float()
|
||||
employer_tolerance = fields.Float()
|
||||
|
||||
information_display = fields.Char("Information", compute='_compute_information_display')
|
||||
|
||||
_timing_start_is_hour = models.Constraint(
|
||||
'CHECK(0 <= timing_start AND timing_start < 24)',
|
||||
"Timing Start is an hour of the day",
|
||||
)
|
||||
_timing_stop_is_hour = models.Constraint(
|
||||
'CHECK(0 <= timing_stop AND timing_stop <= 24)',
|
||||
"Timing Stop is an hour of the day",
|
||||
)
|
||||
|
||||
# Quantity rule well defined
|
||||
@api.constrains('base_off', 'expected_hours', 'quantity_period')
|
||||
def _check_expected_hours(self):
|
||||
for rule in self:
|
||||
if (
|
||||
rule.base_off == 'quantity'
|
||||
and not rule.expected_hours_from_contract
|
||||
and not rule.expected_hours
|
||||
):
|
||||
raise ValidationError(self.env._("Rule '%(name)s' is based off quantity, but the usual amount of work hours is not specified", name=rule.name))
|
||||
|
||||
if rule.base_off == 'quantity' and not rule.quantity_period:
|
||||
raise ValidationError(self.env._("Rule '%(name)s' is based off quantity, but the period is not specified", name=rule.name))
|
||||
|
||||
# Timing rule well defined
|
||||
@api.constrains('base_off', 'timing_type', 'resource_calendar_id')
|
||||
def _check_work_schedule(self):
|
||||
for rule in self:
|
||||
if (
|
||||
rule.base_off == 'timing'
|
||||
and rule.timing_type == 'schedule'
|
||||
and not rule.resource_calendar_id
|
||||
):
|
||||
raise ValidationError(self.env._("Rule '%(name)s' is based off timing, but the work schedule is not specified", name=rule.name))
|
||||
|
||||
def _get_local_time_start(self, date, tz):
|
||||
self.ensure_one()
|
||||
ret = _midnight(date) + relativedelta(hours=self.timing_start)
|
||||
return _naive_utc(tz.localize(ret))
|
||||
|
||||
def _get_local_time_stop(self, date, tz):
|
||||
self.ensure_one()
|
||||
if self.timing_stop == 24:
|
||||
ret = datetime.combine(date, datetime.max.time())
|
||||
return _naive_utc(tz.localize(ret))
|
||||
ret = _midnight(date) + relativedelta(hours=self.timing_stop)
|
||||
return _naive_utc(tz.localize(ret))
|
||||
|
||||
def _get1_timing_overtime_intervals(self, attendances, version_map):
|
||||
self.ensure_one()
|
||||
attendances.employee_id.ensure_one()
|
||||
assert self.base_off == 'timing'
|
||||
|
||||
employee = attendances.employee_id
|
||||
start_dt = min(attendances.mapped('check_in'))
|
||||
end_dt = max(attendances.mapped('check_out'))
|
||||
|
||||
if self.timing_type in ['work_days', 'non_work_days']:
|
||||
company = self.company_id or employee.company_id
|
||||
unusual_days = company.resource_calendar_id._get_unusual_days(start_dt, end_dt, company_id=company)
|
||||
|
||||
attendance_intervals = []
|
||||
for date, day_attendances in attendances.filtered(
|
||||
lambda att: unusual_days.get(att.date.strftime('%Y-%m-%d'), None) == (self.timing_type == 'non_work_days')
|
||||
).grouped('date').items():
|
||||
tz = timezone(version_map[employee][date]._get_tz())
|
||||
time_start = self._get_local_time_start(date, tz)
|
||||
time_stop = self._get_local_time_stop(date, tz)
|
||||
|
||||
attendance_intervals.extend([(
|
||||
max(time_start, attendance.check_in),
|
||||
min(time_stop, attendance.check_out),
|
||||
attendance,
|
||||
) for attendance in day_attendances
|
||||
if time_start <= attendance.check_out and attendance.check_in <= time_stop
|
||||
])
|
||||
|
||||
overtime_intervals = Intervals(attendance_intervals, keep_distinct=True)
|
||||
else:
|
||||
attendance_intervals = [
|
||||
(att.check_in, att.check_out, att)
|
||||
for att in attendances
|
||||
]
|
||||
resource = attendances.employee_id.resource_id
|
||||
# Just use last version for now
|
||||
last_version = version_map[employee][max(attendances.mapped('date'))]
|
||||
tz = timezone(last_version._get_tz())
|
||||
if self.timing_type == 'schedule':
|
||||
work_schedule = self.resource_calendar_id
|
||||
work_intervals = Intervals()
|
||||
for lunch in [False, True]:
|
||||
work_intervals |= Intervals(
|
||||
(_naive_utc(start), _naive_utc(end), records)
|
||||
for (start, end, records)
|
||||
in work_schedule._attendance_intervals_batch(
|
||||
utc.localize(start_dt),
|
||||
utc.localize(end_dt),
|
||||
resource,
|
||||
tz=tz,
|
||||
lunch=lunch,
|
||||
)[resource.id]
|
||||
)
|
||||
overtime_intervals = Intervals(attendance_intervals, keep_distinct=True) - work_intervals
|
||||
elif self.timing_type == 'leave':
|
||||
# TODO: completely untested
|
||||
leave_intervals = last_version.resource_calendar_id._leave_intervals_batch(
|
||||
utc.localize(start_dt),
|
||||
utc.localize(end_dt),
|
||||
resource,
|
||||
tz=tz,
|
||||
)[resource.id]
|
||||
overtime_intervals = Intervals(attendance_intervals, keep_distinct=True) & leave_intervals
|
||||
|
||||
if self.employer_tolerance:
|
||||
overtime_intervals = Intervals((
|
||||
ot for ot in overtime_intervals
|
||||
if _time_delta_hours(ot[1] - ot[0]) >= self.employer_tolerance
|
||||
),
|
||||
keep_distinct=True,
|
||||
)
|
||||
return overtime_intervals
|
||||
|
||||
@api.model
|
||||
def _get_periods(self):
|
||||
return [name for (name, _) in self._fields['quantity_period'].selection]
|
||||
|
||||
@api.model
|
||||
def _get_period_keys(self, date):
|
||||
return {
|
||||
'day': date,
|
||||
# use sunday as key for whole week;
|
||||
# also determines which version we use for the whole week
|
||||
'week': date + relativedelta(days=6 - date.weekday()),
|
||||
}
|
||||
|
||||
def _get_expected_hours_from_contract(self, date, version, period='day'):
|
||||
# todo : improve performance with batch on mapped versions
|
||||
date_start = date
|
||||
date_end = date
|
||||
if period == 'week':
|
||||
date_start = date_start - relativedelta(days=date_start.weekday()) # Set to Monday
|
||||
date_end = date_start + relativedelta(days=6) # Set to Sunday
|
||||
date_start = datetime.combine(date_start, datetime.min.time())
|
||||
date_end = datetime.combine(date_end, datetime.max.time())
|
||||
expected_work_time = version.employee_id._employee_attendance_intervals(
|
||||
utc.localize(date_start),
|
||||
utc.localize(date_end)
|
||||
)
|
||||
delta = sum((i[1] - i[0]).total_seconds() for i in expected_work_time)
|
||||
expected_hours = delta / 3600.0
|
||||
|
||||
return expected_hours
|
||||
|
||||
def _get_daterange_overtime_intervals_for_quantity_rule(self, start, stop, attendance_intervals, schedule): # TODO: TO REMOVE IN MASTER
|
||||
overtime_intervals, _ = self._get_daterange_overtime_undertime_intervals_for_quantity_rule(start, stop, attendance_intervals, schedule)
|
||||
return overtime_intervals
|
||||
|
||||
def _get_daterange_overtime_undertime_intervals_for_quantity_rule(self, start, stop, attendance_intervals, schedule):
|
||||
self.ensure_one()
|
||||
expected_duration = self.expected_hours
|
||||
attendances_interval_without_lunch = []
|
||||
intervals_attendance_by_attendance = defaultdict(Intervals)
|
||||
attendances = self.env['hr.attendance']
|
||||
for (a_start, a_stop, attendance) in attendance_intervals:
|
||||
attendances += attendance
|
||||
intervals_attendance_by_attendance[attendance] = (Intervals([(a_start, a_stop, self.env['resource.calendar'])]) - (schedule['lunch'] - schedule['leave'])) &\
|
||||
Intervals([(start, stop, self.env['resource.calendar'])])
|
||||
attendances_interval_without_lunch.extend(intervals_attendance_by_attendance[attendance]._items)
|
||||
|
||||
if self.expected_hours_from_contract:
|
||||
period_schedule = (schedule['work'] - schedule['leave']) & Intervals([(start, stop, self.env['resource.calendar'])])
|
||||
expected_duration = sum_intervals(period_schedule)
|
||||
|
||||
overtime_amount = sum_intervals(Intervals(attendances_interval_without_lunch)) - expected_duration
|
||||
employee = attendances.employee_id
|
||||
company = self.company_id or employee.company_id
|
||||
if company.absence_management and float_compare(overtime_amount, -self.employee_tolerance, 5) == -1:
|
||||
last_attendance = sorted(intervals_attendance_by_attendance.keys(), key=lambda att: att.check_out)[-1]
|
||||
return {}, {last_attendance: [(overtime_amount, self)]}
|
||||
|
||||
if float_compare(overtime_amount, self.employer_tolerance, 5) != 1:
|
||||
return {}, {}
|
||||
|
||||
overtime_intervals = defaultdict(list)
|
||||
remaining_duration = expected_duration
|
||||
remanining_overtime_amount = overtime_amount
|
||||
# Attendances are sorted by check_in asc
|
||||
for attendance in attendances.sorted('check_in'):
|
||||
for start, stop, _cal in intervals_attendance_by_attendance[attendance]:
|
||||
interval_duration = (stop - start).total_seconds() / 3600
|
||||
if remaining_duration >= interval_duration:
|
||||
remaining_duration -= interval_duration
|
||||
continue
|
||||
interval_overtime_duration = interval_duration
|
||||
if remaining_duration != 0:
|
||||
interval_overtime_duration = interval_duration - remaining_duration
|
||||
new_start = stop - timedelta(hours=interval_overtime_duration)
|
||||
remaining_duration = 0
|
||||
overtime_intervals[attendance].append((new_start, stop, self))
|
||||
remanining_overtime_amount = remanining_overtime_amount - interval_overtime_duration
|
||||
if remanining_overtime_amount <= 0:
|
||||
return overtime_intervals, {}
|
||||
return overtime_intervals, {}
|
||||
|
||||
def _get_all_overtime_intervals_for_quantity_rule(self, attendances_by_periods_by_employee, schedule_by_employee): # TODO: TO REMOVE IN MASTER
|
||||
overtime_by_employee_by_attendance, _ = self._get_all_overtime_undertime_intervals_for_quantity_rule(attendances_by_periods_by_employee, schedule_by_employee)
|
||||
return overtime_by_employee_by_attendance
|
||||
|
||||
def _get_all_overtime_undertime_intervals_for_quantity_rule(self, attendances_by_periods_by_employee, schedule_by_employee):
|
||||
def _merge_overtime_dict(d1, d2):
|
||||
for attendance, overtime_list in d2.items():
|
||||
d1[attendance].extend(overtime_list)
|
||||
|
||||
overtime_by_employee_by_attendance = defaultdict(lambda: defaultdict(list))
|
||||
undertime_by_employee_by_attendance = defaultdict(lambda: defaultdict(list))
|
||||
for employee, duration_and_amount_by_periods in attendances_by_periods_by_employee.items():
|
||||
schedule = schedule_by_employee['schedule'][employee]
|
||||
schedule['leave'] = schedule_by_employee['leave'][employee]
|
||||
fully_flex_schedule = schedule_by_employee['fully_flexible'][employee]
|
||||
for day, attendance_interval in duration_and_amount_by_periods.items():
|
||||
for rule in self:
|
||||
start = datetime.combine(day, datetime.min.time())
|
||||
if rule.quantity_period == 'week':
|
||||
start -= relativedelta(days=6)
|
||||
stop = datetime.combine(day, datetime.max.time())
|
||||
if not (Intervals([(start, stop, self.env['resource.calendar'])]) - fully_flex_schedule): # employee is fully flexible
|
||||
continue
|
||||
rule_overtime_list_by_attendance, rule_undertime_list_by_attendance = rule._get_daterange_overtime_undertime_intervals_for_quantity_rule(start, stop, attendance_interval, schedule)
|
||||
_merge_overtime_dict(overtime_by_employee_by_attendance[employee], rule_overtime_list_by_attendance)
|
||||
_merge_overtime_dict(undertime_by_employee_by_attendance[employee], rule_undertime_list_by_attendance)
|
||||
return overtime_by_employee_by_attendance, undertime_by_employee_by_attendance
|
||||
|
||||
def _get_rules_intervals_by_timing_type(self, min_check_in, max_check_out, employees, schedules_intervals_by_employee):
|
||||
|
||||
def _generate_days_intervals(intervals):
|
||||
days_intervals = []
|
||||
dates = set()
|
||||
for interval in intervals:
|
||||
start_datetime = interval[0]
|
||||
if start_datetime.time() == datetime.max.time():
|
||||
start_datetime += relativedelta(days=1)
|
||||
start_day = start_datetime.date()
|
||||
stop_datetime = interval[1]
|
||||
if stop_datetime.time() == datetime.min.time():
|
||||
stop_datetime -= relativedelta(days=1)
|
||||
stop_day = stop_datetime.date()
|
||||
if stop_day < start_day:
|
||||
continue
|
||||
start = datetime.combine(start_day, datetime.min.time())
|
||||
stop = datetime.combine(stop_day, datetime.max.time())
|
||||
for day in rrule(freq=DAILY, dtstart=start, until=stop):
|
||||
dates.add(day.date())
|
||||
for date in dates:
|
||||
days_intervals.append(
|
||||
(
|
||||
datetime.combine(date, datetime.min.time()),
|
||||
datetime.combine(date, datetime.max.time()),
|
||||
self.env['resource.calendar']
|
||||
)
|
||||
)
|
||||
return Intervals(days_intervals, keep_distinct=True)
|
||||
|
||||
def _invert_intervals(intervals, first_start, last_stop):
|
||||
# Redefintion of the method to return an interval
|
||||
items = []
|
||||
prev_stop = first_start
|
||||
if not intervals:
|
||||
return Intervals([(first_start, last_stop, self.env['resource.calendar'])])
|
||||
for start, stop, record in sorted(intervals):
|
||||
if prev_stop and prev_stop < start and (float_compare((last_stop - start).total_seconds(), 0, precision_digits=1) >= 0):
|
||||
items.append((prev_stop, start, record))
|
||||
prev_stop = max(prev_stop, stop)
|
||||
if last_stop and prev_stop < last_stop:
|
||||
items.append((prev_stop, last_stop, record))
|
||||
return Intervals(items, keep_distinct=True)
|
||||
|
||||
timing_rule_by_timing_type = self.grouped('timing_type')
|
||||
timing_type_set = set(timing_rule_by_timing_type.keys())
|
||||
|
||||
intervals_by_timing_type = {
|
||||
'leave': schedules_intervals_by_employee['leave'],
|
||||
'schedule': defaultdict(lambda: defaultdict(Intervals)),
|
||||
'work_days': defaultdict(),
|
||||
'non_work_days': defaultdict()
|
||||
}
|
||||
|
||||
for employee in employees:
|
||||
if {'work_days', 'non_work_days'} & timing_type_set:
|
||||
intervals_by_timing_type['work_days'][employee] = _generate_days_intervals(
|
||||
schedules_intervals_by_employee['schedule'][employee]['work'] - schedules_intervals_by_employee['leave'][employee]
|
||||
)
|
||||
if 'non_work_days' in timing_type_set:
|
||||
intervals_by_timing_type['non_work_days'][employee] = _generate_days_intervals(
|
||||
_invert_intervals(
|
||||
intervals_by_timing_type['work_days'][employee],
|
||||
datetime.combine(min_check_in, datetime.min.time()),
|
||||
datetime.combine(max_check_out, datetime.max.time())
|
||||
)
|
||||
)
|
||||
if 'schedule' in timing_type_set:
|
||||
for calendar in timing_rule_by_timing_type['schedule'].resource_calendar_id:
|
||||
start_datetime = utc.localize(datetime.combine(min_check_in, datetime.min.time())) - relativedelta(days=1) # to avoid timezone shift
|
||||
stop_datetime = utc.localize(datetime.combine(max_check_out, datetime.max.time())) + relativedelta(days=1) # to avoid timezone shift
|
||||
interval = calendar._attendance_intervals_batch(start_datetime, stop_datetime, lunch=True)[False]
|
||||
interval |= calendar._attendance_intervals_batch(start_datetime, stop_datetime)[False]
|
||||
naive_interval = Intervals([(
|
||||
i_start.replace(tzinfo=None),
|
||||
i_stop.replace(tzinfo=None),
|
||||
i_model
|
||||
) for i_start, i_stop, i_model in interval._items])
|
||||
calendar_intervals = _invert_intervals(
|
||||
naive_interval,
|
||||
start_datetime.replace(tzinfo=None),
|
||||
stop_datetime.replace(tzinfo=None)
|
||||
)
|
||||
intervals_by_timing_type['schedule'][calendar.id].update(
|
||||
{employee: calendar_intervals for employee in employees}
|
||||
)
|
||||
return intervals_by_timing_type
|
||||
|
||||
def _get_all_overtime_intervals_for_timing_rule(self, min_check_in, max_check_out, attendances, schedules_intervals_by_employee):
|
||||
|
||||
def _fill_overtime(employees, rules, intervals, attendances_intervals):
|
||||
if not intervals:
|
||||
return
|
||||
for employee in employees:
|
||||
intersetion_interval_for_attendance = attendances_intervals[employee] & intervals[employee]
|
||||
overtime_interval_list = defaultdict(list)
|
||||
for (start, stop, attendance) in intersetion_interval_for_attendance:
|
||||
overtime_interval_list[attendance].append((start, stop, rules))
|
||||
for attendance, attendance_intervals_list in overtime_interval_list.items():
|
||||
overtime_by_employee_by_attendance[employee][attendance] |= Intervals(attendance_intervals_list)
|
||||
|
||||
def _build_day_rule_intervals(employees, rule, intervals):
|
||||
timing_intervals_by_employee = defaultdict(Intervals)
|
||||
start = min(rule.timing_start, rule.timing_stop)
|
||||
stop = max(rule.timing_start, rule.timing_stop)
|
||||
for employee in employees:
|
||||
for interval in intervals[employee]:
|
||||
start_datetime = datetime.combine(interval[0].date(), float_to_time(start))
|
||||
stop_datetime = datetime.combine(interval[0].date(), float_to_time(stop))
|
||||
timing_intervals = Intervals([(start_datetime, stop_datetime, self.env['resource.calendar'])])
|
||||
if rule.timing_start > rule.timing_stop:
|
||||
day_start = datetime.combine(interval[0].date(), datetime.min.time())
|
||||
day_end = datetime.combine(interval[0].date(), datetime.max.time())
|
||||
timing_intervals = Intervals([
|
||||
(i_start, i_stop, self.env['resource.calendar'])
|
||||
for i_start, i_stop in invert_intervals([(start_datetime, stop_datetime)], day_start, day_end)])
|
||||
timing_intervals_by_employee[employee] |= timing_intervals
|
||||
return timing_intervals_by_employee
|
||||
|
||||
employees = attendances.employee_id
|
||||
intervals_by_timing_type = self._get_rules_intervals_by_timing_type(
|
||||
min_check_in,
|
||||
max_check_out,
|
||||
employees,
|
||||
schedules_intervals_by_employee
|
||||
)
|
||||
attendances_intervals_by_employee = defaultdict()
|
||||
overtime_by_employee_by_attendance = defaultdict(lambda: defaultdict(Intervals))
|
||||
|
||||
attendances_by_employee = attendances.grouped('employee_id')
|
||||
for employee, emp_attendance in attendances_by_employee.items():
|
||||
attendances_intervals_by_employee[employee] = Intervals([
|
||||
(*(attendance._get_localized_times()), attendance)
|
||||
for attendance in emp_attendance], keep_distinct=True)
|
||||
|
||||
for timing_type, rules in self.grouped('timing_type').items():
|
||||
if timing_type == 'leave':
|
||||
_fill_overtime(employees, rules, intervals_by_timing_type['leave'], attendances_intervals_by_employee)
|
||||
|
||||
elif timing_type == 'schedule':
|
||||
for calendar, rules in rules.grouped('resource_calendar_id').items():
|
||||
outside_calendar_intervals = intervals_by_timing_type['schedule'][calendar.id]
|
||||
_fill_overtime(employees, rules, outside_calendar_intervals, attendances_intervals_by_employee)
|
||||
else:
|
||||
for rule in rules:
|
||||
timing_intervals_by_employee = _build_day_rule_intervals(employees, rule, intervals_by_timing_type[timing_type])
|
||||
_fill_overtime(employees, rule, timing_intervals_by_employee, attendances_intervals_by_employee)
|
||||
return overtime_by_employee_by_attendance
|
||||
|
||||
def _get_overtime_intervals_by_employee_by_attendance(self, min_check_in, max_check_out, attendances, schedules_intervals_by_employee): # TODO: TO REMOVE IN MASTER
|
||||
overtime_by_employee_by_attendance, _ = self._get_overtime_undertime_intervals_by_employee_by_attendance(
|
||||
min_check_in, max_check_out, attendances, schedules_intervals_by_employee)
|
||||
return overtime_by_employee_by_attendance
|
||||
|
||||
def _get_overtime_undertime_intervals_by_employee_by_attendance(self, min_check_in, max_check_out, attendances, schedules_intervals_by_employee):
|
||||
|
||||
def _merge_overtime_dict(d1, d2):
|
||||
for employee, overtime_interval_list in d2.items():
|
||||
for attendance, overtime_list in overtime_interval_list.items():
|
||||
d1[employee][attendance].extend(overtime_list)
|
||||
|
||||
overtime_by_employee_by_attendance = defaultdict(lambda: defaultdict(list))
|
||||
undertime_by_employee_by_attendance = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
quantity_rules = self.filtered_domain([('base_off', '=', 'quantity')])
|
||||
if quantity_rules:
|
||||
attendances_by_periods_by_employee = attendances._get_attendance_by_periods_by_employee()
|
||||
quantity_rule_by_periods = quantity_rules.grouped('quantity_period')
|
||||
for period, rules in quantity_rule_by_periods.items():
|
||||
quantity_overtime_by_employee_by_attendance, quantity_undertime_by_employee_by_attendance = rules._get_all_overtime_undertime_intervals_for_quantity_rule(attendances_by_periods_by_employee[period], schedules_intervals_by_employee)
|
||||
_merge_overtime_dict(overtime_by_employee_by_attendance, quantity_overtime_by_employee_by_attendance)
|
||||
_merge_overtime_dict(undertime_by_employee_by_attendance, quantity_undertime_by_employee_by_attendance)
|
||||
|
||||
timing_rules = (self - quantity_rules)
|
||||
if not timing_rules:
|
||||
return overtime_by_employee_by_attendance, undertime_by_employee_by_attendance
|
||||
|
||||
_merge_overtime_dict(
|
||||
overtime_by_employee_by_attendance,
|
||||
timing_rules._get_all_overtime_intervals_for_timing_rule(
|
||||
min_check_in,
|
||||
max_check_out,
|
||||
attendances,
|
||||
schedules_intervals_by_employee
|
||||
)
|
||||
)
|
||||
return overtime_by_employee_by_attendance, undertime_by_employee_by_attendance
|
||||
|
||||
def _get_overtime_intervals_by_date(self, attendances, version_map): # TO REMOVE IN MASTER
|
||||
""" return all overtime over the attendances (all of the SAME employee)
|
||||
as a list of `Intervals` sets with the rule as the recordset
|
||||
generated by `timing` rules in self
|
||||
"""
|
||||
attendances.employee_id.ensure_one()
|
||||
employee = attendances.employee_id
|
||||
if not attendances:
|
||||
return [Intervals(keep_distinct=True)]
|
||||
|
||||
# Timing based
|
||||
timing_intervals_by_date = defaultdict(list)
|
||||
all_timing_overtime_intervals = Intervals(keep_distinct=True)
|
||||
for rule in self.filtered(lambda r: r.base_off == 'timing'):
|
||||
new_intervals = rule._get1_timing_overtime_intervals(attendances, version_map)
|
||||
all_timing_overtime_intervals |= new_intervals
|
||||
for start, end, attendance in new_intervals:
|
||||
timing_intervals_by_date[attendance.date].append((start, end, rule))
|
||||
# timing_intervals_list.append(
|
||||
# Intervals((start, end, (rule, attendance.date)) for (start, end, attendance) in intervals)
|
||||
# )
|
||||
# timing_intervals_list.append(
|
||||
# _replace_interval_records(new_intervals, rule)
|
||||
# )
|
||||
|
||||
# Quantity Based
|
||||
periods = self._get_periods()
|
||||
|
||||
work_hours_by = {period: defaultdict(lambda: 0) for period in periods}
|
||||
attendances_by = {period: defaultdict(list) for period in periods}
|
||||
overtime_hours_by = {period: defaultdict(lambda: 0) for period in periods}
|
||||
overtimes_by = {period: defaultdict(list) for period in periods}
|
||||
for attendance in attendances:
|
||||
for period, key_date in self._get_period_keys(attendance.date).items():
|
||||
work_hours_by[period][key_date] += attendance.worked_hours
|
||||
attendances_by[period][key_date].append(
|
||||
(attendance.check_in, attendance.check_out, attendance)
|
||||
)
|
||||
for start, end, attendance in all_timing_overtime_intervals:
|
||||
for period, key_date in self._get_period_keys(attendance.date).items():
|
||||
overtime_hours_by[period][key_date] += _time_delta_hours(end - start)
|
||||
overtimes_by[period][key_date].append((start, end, attendance))
|
||||
|
||||
# list -> Intervals
|
||||
for period in periods:
|
||||
overtimes_by[period] = defaultdict(
|
||||
lambda: Intervals(keep_distinct=True),
|
||||
{
|
||||
date: Intervals(ots, keep_distinct=True)
|
||||
for date, ots in overtimes_by[period].items()
|
||||
}
|
||||
)
|
||||
# non overtime attendances
|
||||
attendances_by[period] = defaultdict(
|
||||
lambda: Intervals(keep_distinct=True),
|
||||
{
|
||||
date: Intervals(atts, keep_distinct=True) - overtimes_by[period][date]
|
||||
for date, atts in attendances_by[period].items()
|
||||
}
|
||||
)
|
||||
|
||||
quantity_intervals_by_date = defaultdict(list)
|
||||
for rule in self.filtered(lambda r: r.base_off == 'quantity').sorted(
|
||||
lambda r: {p: i for i, p in enumerate(periods)}[r.quantity_period]
|
||||
):
|
||||
period = rule.quantity_period
|
||||
for date in attendances_by[period]:
|
||||
if rule.expected_hours_from_contract:
|
||||
expected_hours = self._get_expected_hours_from_contract(date, version_map[employee][date], period)
|
||||
else:
|
||||
expected_hours = rule.expected_hours
|
||||
|
||||
overtime_quantity = work_hours_by[period][date] - expected_hours
|
||||
# if overtime_quantity <= -rule.employee_tolerance and rule.undertime: make negative adjustment
|
||||
if overtime_quantity <= 0 or overtime_quantity <= rule.employer_tolerance:
|
||||
continue
|
||||
if overtime_quantity < overtime_hours_by[period][date]:
|
||||
for start, end, attendance in _last_hours_as_intervals(
|
||||
starting_intervals=overtimes_by[period][date],
|
||||
hours=overtime_quantity,
|
||||
):
|
||||
quantity_intervals_by_date[attendance.date].append((start, end, rule))
|
||||
else:
|
||||
new_intervals = _last_hours_as_intervals(
|
||||
starting_intervals=attendances_by[period][date],
|
||||
hours=overtime_quantity - overtime_hours_by[period][date],
|
||||
)
|
||||
|
||||
# Uncommenting this changes the logic of how rules for different periods will interact.
|
||||
# Would make it so weekly overtimes try to overlap daily overtimes as much as possible
|
||||
# for outer_period, key_date in self._get_period_keys(date).items():
|
||||
# overtimes_by[outer_period][key_date] |= new_intervals
|
||||
# attendances_by[outer_period][key_date] -= new_intervals
|
||||
# overtime_hours_by[outer_period][key_date] += sum(
|
||||
# (end - start).seconds / 3600
|
||||
# for (start, end, _) in new_intervals
|
||||
# )
|
||||
|
||||
for start, end, attendance in (
|
||||
new_intervals | overtimes_by[period][date]
|
||||
):
|
||||
date = attendance[0].date
|
||||
quantity_intervals_by_date[date].append((start, end, rule))
|
||||
intervals_by_date = {}
|
||||
for date in quantity_intervals_by_date.keys() | timing_intervals_by_date.keys():
|
||||
intervals_by_date[date] = _record_overlap_intervals([
|
||||
*timing_intervals_by_date[date],
|
||||
*quantity_intervals_by_date[date],
|
||||
])
|
||||
return intervals_by_date
|
||||
|
||||
def _generate_overtime_vals(self, employee, attendances, version_map): # TO REMOVE IN MASTER
|
||||
vals = []
|
||||
for date, intervals in self._get_overtime_intervals_by_date(attendances, version_map).items():
|
||||
vals.extend([
|
||||
{
|
||||
'time_start': start,
|
||||
'time_stop': stop,
|
||||
'duration': _time_delta_hours(stop - start),
|
||||
'employee_id': employee.id,
|
||||
'date': date,
|
||||
'rule_ids': rules.ids,
|
||||
**rules._extra_overtime_vals(),
|
||||
}
|
||||
for start, stop, rules in intervals
|
||||
])
|
||||
return vals
|
||||
|
||||
def _generate_overtime_vals_v2(self, min_check_in, max_check_out, attendances, schedules_intervals_by_employee): # SHOULD BE RENAME IN MASTER
|
||||
vals = []
|
||||
|
||||
def _add_overtime_val(attendance, duration_by_day_by_rules):
|
||||
for day, duration_by_rules in duration_by_day_by_rules.items():
|
||||
for rules, duration in duration_by_rules.items():
|
||||
vals.append({
|
||||
'time_start': attendance.check_in,
|
||||
'time_stop': attendance.check_out,
|
||||
'duration': round(duration, 3),
|
||||
'employee_id': employee.id,
|
||||
'date': day,
|
||||
'rule_ids': rules.ids,
|
||||
**rules._extra_overtime_vals(),
|
||||
})
|
||||
|
||||
overtimes, undertimes = self._get_overtime_undertime_intervals_by_employee_by_attendance(min_check_in, max_check_out, attendances, schedules_intervals_by_employee)
|
||||
for employee, intervals_by_attendance in overtimes.items():
|
||||
tz = timezone(employee.sudo()._get_tz())
|
||||
for attendance, intervals in intervals_by_attendance.items():
|
||||
duration_by_day_by_rules = defaultdict(lambda: defaultdict(float))
|
||||
record_overlap_intervals = _record_overlap_intervals(intervals)
|
||||
for start, stop, rules in record_overlap_intervals:
|
||||
date = start.astimezone(tz).date()
|
||||
duration_by_day_by_rules[date][rules] += (stop - start).total_seconds() / 3600
|
||||
_add_overtime_val(attendance, duration_by_day_by_rules)
|
||||
|
||||
for employee, intervals_by_attendance in undertimes.items():
|
||||
tz = timezone(employee.sudo()._get_tz())
|
||||
for attendance, intervals in intervals_by_attendance.items():
|
||||
date = attendance.check_in.astimezone(tz).date()
|
||||
duration_by_day_by_rules = defaultdict(lambda: defaultdict(float))
|
||||
min_duration_tuple = max(intervals, key=lambda x: x[0])
|
||||
duration, rules = min_duration_tuple
|
||||
duration_by_day_by_rules[date][rules] += duration
|
||||
_add_overtime_val(attendance, duration_by_day_by_rules)
|
||||
return vals
|
||||
|
||||
def _extra_overtime_vals(self):
|
||||
paid_rules = self.filtered('paid')
|
||||
if not paid_rules:
|
||||
return {'amount_rate': 0.0}
|
||||
|
||||
max_rate_rule = max(paid_rules, key=lambda r: (r.amount_rate, r.sequence))
|
||||
if self.ruleset_id.rate_combination_mode == 'max':
|
||||
combined_rate = max_rate_rule.amount_rate
|
||||
if self.ruleset_id.rate_combination_mode == 'sum':
|
||||
combined_rate = sum((r.amount_rate - 1. for r in paid_rules), start=1.)
|
||||
|
||||
return {
|
||||
'amount_rate': combined_rate,
|
||||
}
|
||||
|
||||
def _compute_information_display(self):
|
||||
timing_types = dict(self._fields['timing_type'].selection)
|
||||
for rule in self:
|
||||
if rule.base_off == 'quantity':
|
||||
if rule.expected_hours_from_contract:
|
||||
rule.information_display = self.env._("From Employee")
|
||||
continue
|
||||
rule.information_display = self.env._(
|
||||
"%(nb_hours)d h / %(period)s",
|
||||
nb_hours=rule.expected_hours,
|
||||
period={
|
||||
'day': self.env._('day'),
|
||||
'week': self.env._('week'),
|
||||
}[rule.quantity_period],
|
||||
)
|
||||
else:
|
||||
if rule.timing_type == 'schedule':
|
||||
rule.information_display = self.env._(
|
||||
"Outside Schedule: %(schedule_name)s",
|
||||
schedule_name=rule.resource_calendar_id.name,
|
||||
)
|
||||
continue
|
||||
rule.information_display = timing_types[rule.timing_type]
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class HrAttendanceOvertimeRuleset(models.Model):
|
||||
_name = 'hr.attendance.overtime.ruleset'
|
||||
_description = "Overtime Ruleset"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
description = fields.Html()
|
||||
rule_ids = fields.One2many('hr.attendance.overtime.rule', 'ruleset_id')
|
||||
company_id = fields.Many2one('res.company', "Company", default=lambda self: self.env.company)
|
||||
country_id = fields.Many2one(
|
||||
'res.country',
|
||||
default=lambda self: self.env.company.country_id,
|
||||
)
|
||||
rate_combination_mode = fields.Selection([
|
||||
('max', "Maximum Rate"),
|
||||
('sum', "Sum of all rates"),
|
||||
],
|
||||
required=True,
|
||||
default='max',
|
||||
string="Rate Combination Mode",
|
||||
help=(
|
||||
"Controls how the rates from the different rules that apply are combined.\n"
|
||||
" Max: use the highest rate. (e.g.: combined for 150% and 120 = 150%)\n"
|
||||
" Sum: sum the *extra* pay (i.e. above 100%).\n"
|
||||
" e.g.: combined rate for 150% & 120% = 100% (baseline) + (150-100)% + (120-100)% = 170%\n"
|
||||
),
|
||||
)
|
||||
rules_count = fields.Integer(compute='_compute_rules_count')
|
||||
active = fields.Boolean(default=True, readonly=False)
|
||||
|
||||
def _compute_rules_count(self):
|
||||
for ruleset in self:
|
||||
ruleset.rules_count = len(ruleset.rule_ids)
|
||||
|
||||
def _attendances_to_regenerate_for(self):
|
||||
self.ensure_one()
|
||||
elligible_version = self.env['hr.version'].search([('ruleset_id', '=', self.id)])
|
||||
if not elligible_version:
|
||||
return self.env['hr.attendance']
|
||||
elligible_attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', 'in', elligible_version.employee_id.ids),
|
||||
('date', '>=', min(elligible_version.mapped('date_version'))),
|
||||
])
|
||||
return elligible_attendances
|
||||
|
||||
def action_regenerate_overtimes(self):
|
||||
self._attendances_to_regenerate_for()._update_overtime()
|
||||
|
|
@ -1,96 +1,126 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import pytz
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo.tools.intervals import Intervals
|
||||
from odoo import models, fields, api, exceptions, _
|
||||
from odoo.tools import float_round
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = "hr.employee"
|
||||
|
||||
attendance_manager_id = fields.Many2one(
|
||||
'res.users', store=True, readonly=False,
|
||||
string="Attendance Approver",
|
||||
domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
|
||||
groups="hr_attendance.group_hr_attendance_officer",
|
||||
help="The user set in Attendance will access the attendance of the employee through the dedicated app and will be able to edit them.")
|
||||
attendance_ids = fields.One2many(
|
||||
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
last_attendance_id = fields.Many2one(
|
||||
'hr.attendance', compute='_compute_last_attendance_id', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
last_check_in = fields.Datetime(
|
||||
related='last_attendance_id.check_in', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user", tracking=False)
|
||||
last_check_out = fields.Datetime(
|
||||
related='last_attendance_id.check_out', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user", tracking=False)
|
||||
attendance_state = fields.Selection(
|
||||
string="Attendance Status", compute='_compute_attendance_state',
|
||||
selection=[('checked_out', "Checked out"), ('checked_in', "Checked in")],
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
hours_last_month = fields.Float(
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_last_month = fields.Float(compute='_compute_hours_last_month')
|
||||
hours_last_month_overtime = fields.Float(compute='_compute_hours_last_month')
|
||||
hours_today = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_previously_today = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
last_attendance_worked_hours = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_last_month_display = fields.Char(
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
compute='_compute_hours_last_month', groups="hr.group_hr_user")
|
||||
overtime_ids = fields.One2many(
|
||||
'hr.attendance.overtime', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
total_overtime = fields.Float(
|
||||
compute='_compute_total_overtime', compute_sudo=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
'hr.attendance.overtime.line', 'employee_id', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
total_overtime = fields.Float(compute='_compute_total_overtime', compute_sudo=True)
|
||||
display_extra_hours = fields.Boolean(related='company_id.hr_attendance_display_overtime')
|
||||
|
||||
@api.depends('overtime_ids.duration', 'attendance_ids')
|
||||
ruleset_id = fields.Many2one(readonly=False, related="version_id.ruleset_id", inherited=True, groups="hr.group_hr_manager")
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
officer_group = self.env.ref('hr_attendance.group_hr_attendance_officer', raise_if_not_found=False)
|
||||
group_updates = []
|
||||
for vals in vals_list:
|
||||
if officer_group and vals.get('attendance_manager_id'):
|
||||
group_updates.append((4, vals['attendance_manager_id']))
|
||||
if group_updates:
|
||||
officer_group.sudo().write({'user_ids': group_updates})
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
old_officers = self.env['res.users']
|
||||
if 'attendance_manager_id' in vals:
|
||||
old_officers = self.attendance_manager_id
|
||||
# Officer was added
|
||||
if vals['attendance_manager_id']:
|
||||
officer = self.env['res.users'].browse(vals['attendance_manager_id'])
|
||||
officers_group = self.env.ref('hr_attendance.group_hr_attendance_officer', raise_if_not_found=False)
|
||||
if officers_group and not officer.has_group('hr_attendance.group_hr_attendance_officer'):
|
||||
officer.sudo().write({'group_ids': [(4, officers_group.id)]})
|
||||
|
||||
res = super().write(vals)
|
||||
old_officers.sudo()._clean_attendance_officers()
|
||||
|
||||
return res
|
||||
|
||||
@api.depends('overtime_ids.manual_duration', 'overtime_ids', 'overtime_ids.status')
|
||||
def _compute_total_overtime(self):
|
||||
for employee in self:
|
||||
if employee.company_id.hr_attendance_overtime:
|
||||
employee.total_overtime = float_round(sum(employee.overtime_ids.mapped('duration')), 2)
|
||||
else:
|
||||
employee.total_overtime = 0
|
||||
mapped_validated_overtimes = dict(
|
||||
self.env['hr.attendance.overtime.line']._read_group(
|
||||
domain=[('status', '=', 'approved')],
|
||||
groupby=['employee_id'],
|
||||
aggregates=['manual_duration:sum']
|
||||
))
|
||||
|
||||
@api.depends('user_id.im_status', 'attendance_state')
|
||||
def _compute_presence_state(self):
|
||||
"""
|
||||
Override to include checkin/checkout in the presence state
|
||||
Attendance has the second highest priority after login
|
||||
"""
|
||||
super()._compute_presence_state()
|
||||
employees = self.filtered(lambda e: e.hr_presence_state != 'present')
|
||||
employee_to_check_working = self.filtered(lambda e: e.attendance_state == 'checked_out'
|
||||
and e.hr_presence_state == 'to_define')
|
||||
working_now_list = employee_to_check_working._get_employee_working_now()
|
||||
for employee in employees:
|
||||
if employee.attendance_state == 'checked_out' and employee.hr_presence_state == 'to_define' and \
|
||||
employee.id not in working_now_list:
|
||||
employee.hr_presence_state = 'absent'
|
||||
elif employee.attendance_state == 'checked_in':
|
||||
employee.hr_presence_state = 'present'
|
||||
for employee in self:
|
||||
employee.total_overtime = mapped_validated_overtimes.get(employee, 0)
|
||||
|
||||
def _compute_hours_last_month(self):
|
||||
"""
|
||||
Compute hours and overtime hours in the current month, if we are the 15th of october, will compute from 1 oct to 15 oct
|
||||
"""
|
||||
now = fields.Datetime.now()
|
||||
now_utc = pytz.utc.localize(now)
|
||||
for employee in self:
|
||||
tz = pytz.timezone(employee.tz or 'UTC')
|
||||
now_tz = now_utc.astimezone(tz)
|
||||
start_tz = now_tz + relativedelta(months=-1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_tz = now_tz.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
end_tz = now_tz + relativedelta(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_tz = now_tz
|
||||
end_naive = end_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
'&',
|
||||
('check_in', '<=', end_naive),
|
||||
('check_out', '>=', start_naive),
|
||||
])
|
||||
|
||||
current_month_attendances = employee.attendance_ids.filtered(
|
||||
lambda att: att.check_in >= start_naive and att.check_out and att.check_out <= end_naive
|
||||
)
|
||||
hours = 0
|
||||
for attendance in attendances:
|
||||
check_in = max(attendance.check_in, start_naive)
|
||||
check_out = min(attendance.check_out, end_naive)
|
||||
hours += (check_out - check_in).total_seconds() / 3600.0
|
||||
|
||||
overtime_hours = 0
|
||||
for att in current_month_attendances:
|
||||
hours += att.worked_hours or 0
|
||||
overtime_hours += att.validated_overtime_hours or 0
|
||||
employee.hours_last_month = round(hours, 2)
|
||||
employee.hours_last_month_display = "%g" % employee.hours_last_month
|
||||
# overtime_adjustments = sum(
|
||||
# ot.duration or 0
|
||||
# for ot in employee.overtime_ids.filtered(
|
||||
# lambda ot: ot.date >= start_tz.date() and ot.date <= end_tz.date() and ot.adjustment
|
||||
# )
|
||||
# )
|
||||
employee.hours_last_month_overtime = round(overtime_hours, 2)
|
||||
|
||||
def _compute_hours_today(self):
|
||||
now = fields.Datetime.now()
|
||||
|
|
@ -103,23 +133,31 @@ class HrEmployee(models.Model):
|
|||
start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
('employee_id', 'in', employee.ids),
|
||||
('check_in', '<=', now),
|
||||
'|', ('check_out', '>=', start_naive), ('check_out', '=', False),
|
||||
])
|
||||
|
||||
], order='check_in asc')
|
||||
hours_previously_today = 0
|
||||
worked_hours = 0
|
||||
attendance_worked_hours = 0
|
||||
for attendance in attendances:
|
||||
delta = (attendance.check_out or now) - max(attendance.check_in, start_naive)
|
||||
worked_hours += delta.total_seconds() / 3600.0
|
||||
attendance_worked_hours = delta.total_seconds() / 3600.0
|
||||
worked_hours += attendance_worked_hours
|
||||
hours_previously_today += attendance_worked_hours
|
||||
employee.last_attendance_worked_hours = attendance_worked_hours
|
||||
hours_previously_today -= attendance_worked_hours
|
||||
employee.hours_previously_today = hours_previously_today
|
||||
employee.hours_today = worked_hours
|
||||
|
||||
@api.depends('attendance_ids')
|
||||
def _compute_last_attendance_id(self):
|
||||
current_datetime = fields.Datetime.now()
|
||||
for employee in self:
|
||||
employee.last_attendance_id = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
], limit=1)
|
||||
('employee_id', 'in', employee.ids),
|
||||
('check_in', '<=', current_datetime),
|
||||
], order="check_in desc", limit=1)
|
||||
|
||||
@api.depends('last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id')
|
||||
def _compute_attendance_state(self):
|
||||
|
|
@ -127,56 +165,7 @@ class HrEmployee(models.Model):
|
|||
att = employee.last_attendance_id.sudo()
|
||||
employee.attendance_state = att and not att.check_out and 'checked_in' or 'checked_out'
|
||||
|
||||
@api.model
|
||||
def attendance_scan(self, barcode):
|
||||
""" Receive a barcode scanned from the Kiosk Mode and change the attendances of corresponding employee.
|
||||
Returns either an action or a warning.
|
||||
"""
|
||||
employee = self.sudo().search([('barcode', '=', barcode)], limit=1)
|
||||
if employee:
|
||||
return employee._attendance_action('hr_attendance.hr_attendance_action_kiosk_mode')
|
||||
return {'warning': _("No employee corresponding to Badge ID '%(barcode)s.'") % {'barcode': barcode}}
|
||||
|
||||
def attendance_manual(self, next_action, entered_pin=None):
|
||||
self.ensure_one()
|
||||
attendance_user_and_no_pin = self.user_has_groups(
|
||||
'hr_attendance.group_hr_attendance_user,'
|
||||
'!hr_attendance.group_hr_attendance_use_pin')
|
||||
can_check_without_pin = attendance_user_and_no_pin or (self.user_id == self.env.user and entered_pin is None)
|
||||
if can_check_without_pin or entered_pin is not None and entered_pin == self.sudo().pin:
|
||||
return self._attendance_action(next_action)
|
||||
if not self.user_has_groups('hr_attendance.group_hr_attendance_user'):
|
||||
return {'warning': _('To activate Kiosk mode without pin code, you must have access right as an Officer or above in the Attendance app. Please contact your administrator.')}
|
||||
return {'warning': _('Wrong PIN')}
|
||||
|
||||
def _attendance_action(self, next_action):
|
||||
""" Changes the attendance of the employee.
|
||||
Returns an action to the check in/out message,
|
||||
next_action defines which menu the check in/out message should return to. ("My Attendances" or "Kiosk Mode")
|
||||
"""
|
||||
self.ensure_one()
|
||||
employee = self.sudo()
|
||||
action_message = self.env["ir.actions.actions"]._for_xml_id("hr_attendance.hr_attendance_action_greeting_message")
|
||||
action_message['previous_attendance_change_date'] = employee.last_attendance_id and (employee.last_attendance_id.check_out or employee.last_attendance_id.check_in) or False
|
||||
action_message['employee_name'] = employee.name
|
||||
action_message['barcode'] = employee.barcode
|
||||
action_message['next_action'] = next_action
|
||||
action_message['hours_today'] = employee.hours_today
|
||||
action_message['kiosk_delay'] = employee.company_id.attendance_kiosk_delay * 1000
|
||||
|
||||
if employee.user_id:
|
||||
modified_attendance = employee.with_user(employee.user_id).sudo()._attendance_action_change()
|
||||
else:
|
||||
modified_attendance = employee._attendance_action_change()
|
||||
action_message['attendance'] = modified_attendance.read()[0]
|
||||
action_message['show_total_overtime'] = employee.company_id.hr_attendance_overtime
|
||||
action_message['total_overtime'] = employee.total_overtime
|
||||
# Overtime have an unique constraint on the day, no need for limit=1
|
||||
action_message['overtime_today'] = self.env['hr.attendance.overtime'].sudo().search([
|
||||
('employee_id', '=', employee.id), ('date', '=', fields.Date.context_today(self)), ('adjustment', '=', False)]).duration or 0
|
||||
return {'action': action_message}
|
||||
|
||||
def _attendance_action_change(self):
|
||||
def _attendance_action_change(self, geo_information=None):
|
||||
""" Check In/Check Out action
|
||||
Check In: create a new attendance record
|
||||
Check Out: modify check_out field of appropriate attendance record
|
||||
|
|
@ -185,27 +174,164 @@ class HrEmployee(models.Model):
|
|||
action_date = fields.Datetime.now()
|
||||
|
||||
if self.attendance_state != 'checked_in':
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
}
|
||||
if geo_information:
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
**{'in_%s' % key: geo_information[key] for key in geo_information}
|
||||
}
|
||||
else:
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
}
|
||||
return self.env['hr.attendance'].create(vals)
|
||||
attendance = self.env['hr.attendance'].search([('employee_id', '=', self.id), ('check_out', '=', False)], limit=1)
|
||||
if attendance:
|
||||
attendance.check_out = action_date
|
||||
if geo_information:
|
||||
attendance.write({
|
||||
'check_out': action_date,
|
||||
**{'out_%s' % key: geo_information[key] for key in geo_information}
|
||||
})
|
||||
else:
|
||||
attendance.write({
|
||||
'check_out': action_date
|
||||
})
|
||||
else:
|
||||
raise exceptions.UserError(_('Cannot perform check out on %(empl_name)s, could not find corresponding check in. '
|
||||
'Your attendances have probably been modified manually by human resources.') % {'empl_name': self.sudo().name, })
|
||||
raise exceptions.UserError(_(
|
||||
'Cannot perform check out on %(empl_name)s, could not find corresponding check in. '
|
||||
'Your attendances have probably been modified manually by human resources.',
|
||||
empl_name=self.sudo().name))
|
||||
return attendance
|
||||
|
||||
@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
if 'pin' in groupby or 'pin' in self.env.context.get('group_by', '') or self.env.context.get('no_group_by'):
|
||||
raise exceptions.UserError(_('Such grouping is not allowed.'))
|
||||
return super(HrEmployee, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
||||
def get_overtime_data(self, domain=None, employee_id=None):
|
||||
domain = [] if domain is None else domain
|
||||
validated_overtime = {
|
||||
attendance[0].id: attendance[1]
|
||||
for attendance in self.env["hr.attendance"]._read_group(
|
||||
domain=domain,
|
||||
groupby=['employee_id'],
|
||||
aggregates=['validated_overtime_hours:sum']
|
||||
)
|
||||
}
|
||||
return {"validated_overtime": validated_overtime, "overtime_adjustments": {}}
|
||||
|
||||
def action_open_last_month_attendances(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Attendances This Month"),
|
||||
"res_model": "hr.attendance",
|
||||
"views": [[self.env.ref('hr_attendance.hr_attendance_employee_simple_tree_view').id, "list"]],
|
||||
"context": {
|
||||
"create": 0,
|
||||
"search_default_check_in_filter": 1,
|
||||
"employee_id": self.id,
|
||||
"display_extra_hours": self.display_extra_hours,
|
||||
},
|
||||
"domain": [('employee_id', '=', self.id)]
|
||||
}
|
||||
|
||||
@api.depends("user_id.im_status", "attendance_state")
|
||||
def _compute_presence_state(self):
|
||||
"""
|
||||
Override to include checkin/checkout in the presence state
|
||||
Attendance has the second highest priority after login
|
||||
"""
|
||||
super()._compute_presence_state()
|
||||
employees = self.filtered(lambda e: e.hr_presence_state != "present")
|
||||
employee_to_check_working = self.filtered(lambda e: e.sudo().attendance_state == "checked_out"
|
||||
and e.hr_presence_state == "out_of_working_hour")
|
||||
working_now_list = employee_to_check_working._get_employee_working_now()
|
||||
for employee in employees:
|
||||
if employee.sudo().attendance_state == "checked_out" and employee.hr_presence_state == "out_of_working_hour" and \
|
||||
employee.id in working_now_list:
|
||||
employee.hr_presence_state = "absent"
|
||||
elif employee.sudo().attendance_state == "checked_in":
|
||||
employee.hr_presence_state = "present"
|
||||
|
||||
def _compute_presence_icon(self):
|
||||
res = super()._compute_presence_icon()
|
||||
# All employee must chek in or check out. Everybody must have an icon
|
||||
self.filtered(lambda employee: not employee.show_hr_icon_display).show_hr_icon_display = True
|
||||
for employee in self:
|
||||
employee.show_hr_icon_display = employee.company_id.hr_presence_control_attendance or bool(employee.user_id)
|
||||
return res
|
||||
|
||||
def open_barcode_scanner(self):
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "employee_barcode_scanner",
|
||||
"name": "Badge Scanner"
|
||||
}
|
||||
|
||||
def _get_schedules_by_employee_by_work_type(self, start, stop, version_periods_by_employee):
|
||||
employees_by_calendar = defaultdict(lambda: self.env['hr.employee'])
|
||||
leave_intervals_by_cal_by_resource = defaultdict(lambda: defaultdict(Intervals))
|
||||
attendance_intervals_by_cal = defaultdict(Intervals)
|
||||
lunch_intervals_by_cal = defaultdict(Intervals)
|
||||
|
||||
for employee, intervals in version_periods_by_employee.items():
|
||||
for (_start, _stop, version) in intervals:
|
||||
employees_by_calendar[version.resource_calendar_id] |= employee
|
||||
|
||||
for cal, employees in employees_by_calendar.items():
|
||||
if not cal: # employees are fully flex
|
||||
continue
|
||||
cal_leave_intervals_by_resource = cal._leave_intervals_batch(
|
||||
start,
|
||||
stop,
|
||||
resources=employees.resource_id,
|
||||
)
|
||||
for resource, leave_intervals in cal_leave_intervals_by_resource.items():
|
||||
naive_leave_intervals = Intervals([(
|
||||
i_start.replace(tzinfo=None),
|
||||
i_stop.replace(tzinfo=None),
|
||||
i_model
|
||||
) for (i_start, i_stop, i_model) in leave_intervals])
|
||||
leave_intervals_by_cal_by_resource[cal][resource] = naive_leave_intervals
|
||||
|
||||
cal_attendance_intervals = cal._attendance_intervals_batch(
|
||||
start,
|
||||
stop,
|
||||
)[False]
|
||||
attendance_intervals_by_cal[cal] = Intervals([(
|
||||
i_start.replace(tzinfo=None),
|
||||
i_stop.replace(tzinfo=None),
|
||||
i_model
|
||||
) for (i_start, i_stop, i_model) in cal_attendance_intervals])
|
||||
|
||||
cal_lunch_intervals = cal._attendance_intervals_batch(
|
||||
start,
|
||||
stop,
|
||||
lunch=True
|
||||
)[False]
|
||||
lunch_intervals_by_cal[cal] = Intervals([(
|
||||
i_start.replace(tzinfo=None),
|
||||
i_stop.replace(tzinfo=None),
|
||||
i_model
|
||||
) for (i_start, i_stop, i_model) in cal_lunch_intervals])
|
||||
|
||||
full_schedule_by_employee = {
|
||||
'leave': defaultdict(Intervals),
|
||||
'schedule': defaultdict(lambda: {
|
||||
'work': Intervals([]),
|
||||
'lunch': Intervals([]),
|
||||
}),
|
||||
'fully_flexible': defaultdict(Intervals)
|
||||
}
|
||||
for employee, intervals in version_periods_by_employee.items():
|
||||
for (p_start, p_stop, version) in intervals:
|
||||
interval = Intervals([(p_start.replace(tzinfo=None), p_stop.replace(tzinfo=None), self.env['resource.calendar'])])
|
||||
calendar = version.resource_calendar_id
|
||||
if not calendar:
|
||||
full_schedule_by_employee['fully_flexible'][employee] |= interval
|
||||
continue
|
||||
employee_leaves = leave_intervals_by_cal_by_resource[calendar][employee.resource_id.id]
|
||||
full_schedule_by_employee['leave'][employee] |= employee_leaves & interval
|
||||
employee_attendances = attendance_intervals_by_cal[calendar]
|
||||
full_schedule_by_employee['schedule'][employee]['work'] |= employee_attendances & interval
|
||||
employee_lunches = lunch_intervals_by_cal[calendar]
|
||||
full_schedule_by_employee['schedule'][employee]['lunch'] |= employee_lunches & interval
|
||||
|
||||
return full_schedule_by_employee
|
||||
|
|
|
|||
|
|
@ -3,27 +3,29 @@
|
|||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrEmployeePublic(models.Model):
|
||||
_inherit = 'hr.employee.public'
|
||||
|
||||
# These are required for manual attendance
|
||||
attendance_state = fields.Selection(related='employee_id.attendance_state', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
hours_today = fields.Float(related='employee_id.hours_today', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
hours_last_month = fields.Float(related='employee_id.hours_last_month')
|
||||
hours_last_month_overtime = fields.Float(related='employee_id.hours_last_month_overtime')
|
||||
last_attendance_id = fields.Many2one(related='employee_id.last_attendance_id', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime', readonly=True)
|
||||
attendance_manager_id = fields.Many2one(related='employee_id.attendance_manager_id',
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
last_check_in = fields.Datetime(related='employee_id.last_check_in',
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
last_check_out = fields.Datetime(related='employee_id.last_check_out',
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
display_extra_hours = fields.Boolean(related='company_id.hr_attendance_display_overtime')
|
||||
|
||||
def action_employee_kiosk_confirm(self):
|
||||
def action_open_last_month_attendances(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'name': 'Confirm',
|
||||
'tag': 'hr_attendance_kiosk_confirm',
|
||||
'employee_id': self.id,
|
||||
'employee_name': self.name,
|
||||
'employee_state': self.attendance_state,
|
||||
'employee_hours_today': self.hours_today,
|
||||
}
|
||||
if self.is_user:
|
||||
return self.employee_id.action_open_last_month_attendances()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class HrVersion(models.Model):
|
||||
_name = 'hr.version'
|
||||
_inherit = 'hr.version'
|
||||
|
||||
@api.model
|
||||
def _domain_current_countries(self):
|
||||
return ['|',
|
||||
('country_id', '=', False),
|
||||
('country_id', 'in', self.env.companies.country_id.ids),
|
||||
]
|
||||
|
||||
ruleset_id = fields.Many2one(
|
||||
"hr.attendance.overtime.ruleset",
|
||||
domain=_domain_current_countries,
|
||||
groups="hr.group_hr_manager",
|
||||
tracking=True,
|
||||
default=lambda self: self.env.ref('hr_attendance.hr_attendance_default_ruleset', raise_if_not_found=False),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_versions_by_employee_and_date(self, employee_dates):
|
||||
# for `employee_dates` a dict[employee] -> dates
|
||||
# Generate a 2 level dict[employee][date] -> version
|
||||
employees = self.env['hr.employee'].union(*employee_dates.keys())
|
||||
all_dates = [date for dates in employee_dates.values() for date in dates]
|
||||
if not all_dates:
|
||||
return {}
|
||||
date_to = max(all_dates)
|
||||
all_versions = self.env['hr.version'].search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('date_version', '<=', date_to),
|
||||
# note: no check on date_from because we don't store the version date end
|
||||
])
|
||||
versions_by_employee = all_versions.grouped('employee_id')
|
||||
version_by_employee_and_date = {employee: {} for employee in employees}
|
||||
|
||||
for employee, dates in employee_dates.items():
|
||||
if not (versions := versions_by_employee.get(employee)):
|
||||
continue
|
||||
version_index = 0
|
||||
for date in sorted(dates):
|
||||
if version_index + 1 < len(versions) and date >= versions[version_index + 1].date_version:
|
||||
version_index += 1
|
||||
version_by_employee_and_date[employee][date] = versions[version_index]
|
||||
return version_by_employee_and_date
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.hr_attendance.controllers.main import HrAttendance
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@api.model
|
||||
def lazy_session_info(self):
|
||||
res = super().lazy_session_info()
|
||||
if self.env.user and self.env.user.employee_id:
|
||||
employee = self.env.user.employee_id
|
||||
res['attendance_user_data'] = HrAttendance._get_user_attendance_data(employee)
|
||||
return res
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrUiMenu(models.Model):
|
||||
_inherit = 'ir.ui.menu'
|
||||
|
||||
def _load_menus_blacklist(self):
|
||||
res = super()._load_menus_blacklist()
|
||||
att_menu = self.env.ref('hr_attendance.menu_hr_attendance_attendances_overview', raise_if_not_found=False)
|
||||
if att_menu and self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
res.append(att_menu.id)
|
||||
return res
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.osv.expression import OR
|
||||
import uuid
|
||||
|
||||
from odoo import fields, models, api
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools.urls import urljoin as url_join
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
hr_attendance_overtime = fields.Boolean(string="Count Extra Hours")
|
||||
overtime_start_date = fields.Date(string="Extra Hours Starting Date")
|
||||
overtime_company_threshold = fields.Integer(string="Tolerance Time In Favor Of Company", default=0)
|
||||
overtime_employee_threshold = fields.Integer(string="Tolerance Time In Favor Of Employee", default=0)
|
||||
def _default_company_token(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
# TODO: Remove in master
|
||||
overtime_company_threshold = fields.Integer(string="Tolerance Time In Favor Of Company", default=0)
|
||||
# TODO: Remove in master
|
||||
overtime_employee_threshold = fields.Integer(string="Tolerance Time In Favor Of Employee", default=0)
|
||||
hr_attendance_display_overtime = fields.Boolean(string="Display Extra Hours")
|
||||
attendance_kiosk_mode = fields.Selection([
|
||||
('barcode', 'Barcode / RFID'),
|
||||
('barcode_manual', 'Barcode / RFID and Manual Selection'),
|
||||
|
|
@ -24,51 +29,82 @@ class ResCompany(models.Model):
|
|||
('back', 'Back Camera'),
|
||||
], string='Barcode Source', default='front')
|
||||
attendance_kiosk_delay = fields.Integer(default=10)
|
||||
attendance_kiosk_key = fields.Char(default=lambda s: uuid.uuid4().hex, copy=False, groups='hr_attendance.group_hr_attendance_user')
|
||||
attendance_kiosk_url = fields.Char(compute="_compute_attendance_kiosk_url")
|
||||
attendance_kiosk_use_pin = fields.Boolean(string='Employee PIN Identification')
|
||||
attendance_from_systray = fields.Boolean(string='Attendance From Systray', default=False)
|
||||
attendance_overtime_validation = fields.Selection([
|
||||
('no_validation', 'Automatically Approved'),
|
||||
('by_manager', 'Approved by Manager'),
|
||||
], string='Extra Hours Validation', default='no_validation')
|
||||
auto_check_out = fields.Boolean(string="Automatic Check Out", default=False)
|
||||
auto_check_out_tolerance = fields.Float(default=2, export_string_translation=False)
|
||||
absence_management = fields.Boolean(string="Absence Management", default=False)
|
||||
attendance_device_tracking = fields.Boolean(string="Device & Location Tracking", default=False)
|
||||
|
||||
@api.depends("attendance_kiosk_key")
|
||||
def _compute_attendance_kiosk_url(self):
|
||||
for company in self:
|
||||
company.attendance_kiosk_url = url_join(self.env['res.company'].get_base_url(), '/hr_attendance/%s' % company.attendance_kiosk_key)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ORM Overrides
|
||||
# ---------------------------------------------------------
|
||||
def _init_column(self, column_name):
|
||||
""" Initialize the value of the given column for existing rows.
|
||||
Overridden here because we need to generate different access tokens
|
||||
and by default _init_column calls the default method once and applies
|
||||
it for every record.
|
||||
"""
|
||||
if column_name != 'attendance_kiosk_key':
|
||||
super(ResCompany, self)._init_column(column_name)
|
||||
else:
|
||||
self.env.cr.execute("SELECT id FROM %s WHERE attendance_kiosk_key IS NULL" % self._table)
|
||||
attendance_ids = self.env.cr.dictfetchall()
|
||||
values_args = [(attendance_id['id'], self._default_company_token()) for attendance_id in attendance_ids]
|
||||
query = """
|
||||
UPDATE {table}
|
||||
SET attendance_kiosk_key = vals.token
|
||||
FROM (VALUES %s) AS vals(id, token)
|
||||
WHERE {table}.id = vals.id
|
||||
""".format(table=self._table)
|
||||
self.env.cr.execute_values(query, values_args)
|
||||
|
||||
def write(self, vals):
|
||||
search_domain = False # Overtime to generate
|
||||
delete_domain = False # Overtime to delete
|
||||
|
||||
overtime_enabled_companies = self.filtered('hr_attendance_overtime')
|
||||
# Prevent any further logic if we are disabling the feature
|
||||
is_disabling_overtime = False
|
||||
# If we disable overtime
|
||||
if 'hr_attendance_overtime' in vals and not vals['hr_attendance_overtime'] and overtime_enabled_companies:
|
||||
delete_domain = [('company_id', 'in', overtime_enabled_companies.ids)]
|
||||
vals['overtime_start_date'] = False
|
||||
is_disabling_overtime = True
|
||||
|
||||
start_date = vals.get('hr_attendance_overtime') and vals.get('overtime_start_date')
|
||||
search_domain = Domain.FALSE # Overtime to generate
|
||||
# Also recompute if the threshold have changed
|
||||
if not is_disabling_overtime and (
|
||||
start_date or 'overtime_company_threshold' in vals or 'overtime_employee_threshold' in vals):
|
||||
for company in self:
|
||||
# If we modify the thresholds only
|
||||
if start_date == company.overtime_start_date and \
|
||||
(vals.get('overtime_company_threshold') != company.overtime_company_threshold) or\
|
||||
(vals.get('overtime_employee_threshold') != company.overtime_employee_threshold):
|
||||
search_domain = OR([search_domain, [('employee_id.company_id', '=', company.id)]])
|
||||
# If we enabled the overtime with a start date
|
||||
elif not company.overtime_start_date and start_date:
|
||||
search_domain = OR([search_domain, [
|
||||
('employee_id.company_id', '=', company.id),
|
||||
('check_in', '>=', start_date)]])
|
||||
# If we move the start date into the past
|
||||
elif start_date and company.overtime_start_date > start_date:
|
||||
search_domain = OR([search_domain, [
|
||||
('employee_id.company_id', '=', company.id),
|
||||
('check_in', '>=', start_date),
|
||||
('check_in', '<=', company.overtime_start_date)]])
|
||||
# If we move the start date into the future
|
||||
elif start_date and company.overtime_start_date < start_date:
|
||||
delete_domain = OR([delete_domain, [
|
||||
('company_id', '=', company.id),
|
||||
('date', '<', start_date)]])
|
||||
if 'overtime_company_threshold' in vals or 'overtime_employee_threshold' in vals:
|
||||
# If we modify the thresholds only
|
||||
search_domain = Domain.OR(
|
||||
Domain('employee_id.company_id', '=', company.id)
|
||||
for company in self
|
||||
if (vals.get('overtime_company_threshold') != company.overtime_company_threshold)
|
||||
or (vals.get('overtime_employee_threshold') != company.overtime_employee_threshold)
|
||||
)
|
||||
|
||||
res = super().write(vals)
|
||||
if delete_domain:
|
||||
self.env['hr.attendance.overtime'].search(delete_domain).unlink()
|
||||
if search_domain:
|
||||
if not search_domain.is_false():
|
||||
self.env['hr.attendance'].search(search_domain)._update_overtime()
|
||||
|
||||
return res
|
||||
|
||||
def _regenerate_attendance_kiosk_key(self):
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'attendance_kiosk_key': uuid.uuid4().hex
|
||||
})
|
||||
|
||||
def _check_hr_presence_control(self, at_install):
|
||||
companies = self.env.companies
|
||||
for company in companies:
|
||||
if at_install and company.hr_presence_control_login:
|
||||
company.hr_presence_control_attendance = True
|
||||
if not at_install and company.hr_presence_control_attendance:
|
||||
company.hr_presence_control_login = True
|
||||
|
||||
def _action_open_kiosk_mode(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'target': 'self',
|
||||
'url': f'/hr_attendance/kiosk_mode_menu/{self.env.company.id}',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
|
@ -7,27 +6,30 @@ from odoo import api, fields, models
|
|||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
group_attendance_use_pin = fields.Boolean(
|
||||
string='Employee PIN',
|
||||
implied_group="hr_attendance.group_hr_attendance_use_pin")
|
||||
hr_attendance_overtime = fields.Boolean(
|
||||
string="Count Extra Hours", readonly=False)
|
||||
overtime_start_date = fields.Date(string="Extra Hours Starting Date", readonly=False)
|
||||
# TODO: Remove in master
|
||||
overtime_company_threshold = fields.Integer(
|
||||
string="Tolerance Time In Favor Of Company", readonly=False)
|
||||
# TODO: Remove in master
|
||||
overtime_employee_threshold = fields.Integer(
|
||||
string="Tolerance Time In Favor Of Employee", readonly=False)
|
||||
hr_attendance_display_overtime = fields.Boolean(related='company_id.hr_attendance_display_overtime', readonly=False)
|
||||
attendance_kiosk_mode = fields.Selection(related='company_id.attendance_kiosk_mode', readonly=False)
|
||||
attendance_barcode_source = fields.Selection(related='company_id.attendance_barcode_source', readonly=False)
|
||||
attendance_kiosk_delay = fields.Integer(related='company_id.attendance_kiosk_delay', readonly=False)
|
||||
attendance_kiosk_url = fields.Char(related='company_id.attendance_kiosk_url')
|
||||
attendance_kiosk_use_pin = fields.Boolean(related='company_id.attendance_kiosk_use_pin', readonly=False)
|
||||
attendance_from_systray = fields.Boolean(related="company_id.attendance_from_systray", readonly=False)
|
||||
attendance_overtime_validation = fields.Selection(related="company_id.attendance_overtime_validation", readonly=False)
|
||||
auto_check_out = fields.Boolean(related="company_id.auto_check_out", readonly=False)
|
||||
auto_check_out_tolerance = fields.Float(related="company_id.auto_check_out_tolerance", readonly=False)
|
||||
absence_management = fields.Boolean(related="company_id.absence_management", readonly=False)
|
||||
attendance_device_tracking = fields.Boolean(related="company_id.attendance_device_tracking", readonly=False)
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
res = super(ResConfigSettings, self).get_values()
|
||||
company = self.env.company
|
||||
res.update({
|
||||
'hr_attendance_overtime': company.hr_attendance_overtime,
|
||||
'overtime_start_date': company.overtime_start_date,
|
||||
'overtime_company_threshold': company.overtime_company_threshold,
|
||||
'overtime_employee_threshold': company.overtime_employee_threshold,
|
||||
})
|
||||
|
|
@ -40,10 +42,12 @@ class ResConfigSettings(models.TransientModel):
|
|||
# to avoid recomputing the overtimes several times with
|
||||
# invalid company configurations
|
||||
fields_to_check = [
|
||||
'hr_attendance_overtime',
|
||||
'overtime_start_date',
|
||||
'overtime_company_threshold',
|
||||
'overtime_employee_threshold',
|
||||
]
|
||||
if any(self[field] != company[field] for field in fields_to_check):
|
||||
company.write({field: self[field] for field in fields_to_check})
|
||||
|
||||
def regenerate_kiosk_key(self):
|
||||
if self.env.user.has_group("hr_attendance.group_hr_attendance_user"):
|
||||
self.company_id._regenerate_attendance_kiosk_key()
|
||||
|
|
|
|||
|
|
@ -1,26 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
from odoo import models
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
_inherit = ['res.users']
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
hours_last_month = fields.Float(related='employee_id.hours_last_month')
|
||||
hours_last_month_display = fields.Char(related='employee_id.hours_last_month_display')
|
||||
attendance_state = fields.Selection(related='employee_id.attendance_state')
|
||||
last_check_in = fields.Datetime(related='employee_id.last_attendance_id.check_in')
|
||||
last_check_out = fields.Datetime(related='employee_id.last_attendance_id.check_out')
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime')
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + [
|
||||
'hours_last_month',
|
||||
'hours_last_month_display',
|
||||
'attendance_state',
|
||||
'last_check_in',
|
||||
'last_check_out',
|
||||
'total_overtime'
|
||||
]
|
||||
def _clean_attendance_officers(self):
|
||||
attendance_officers = self.env['hr.employee'].search(
|
||||
[('attendance_manager_id', 'in', self.ids)]).attendance_manager_id
|
||||
officers_to_remove_ids = self - attendance_officers
|
||||
if officers_to_remove_ids:
|
||||
self.env.ref('hr_attendance.group_hr_attendance_officer').user_ids = [(3, user.id) for user in
|
||||
officers_to_remove_ids]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue