[IMP] hr_attendance: compute overtime on attendances
This commit adds a new model `hr.attendance.overtime`.
When an employee checks out, an overtime entry will be created when:
- the option is enabled on the company;
- the employee worked more or less than the number of hours they
were supposed to work that day.
TaskID: 1904850
This commit is contained in:
committed by
William Braeckman
parent
731a173d49
commit
9e6d2da504
@@ -395,6 +395,21 @@ class HrEmployeePrivate(models.Model):
|
||||
except AccessError:
|
||||
employee.is_address_home_a_company = False
|
||||
|
||||
def _get_tz(self):
|
||||
# Finds the first valid timezone in his tz, his work hours tz,
|
||||
# the company calendar tz or UTC and returns it as a string
|
||||
self.ensure_one()
|
||||
return self.tz or\
|
||||
self.resource_calendar_id.tz or\
|
||||
self.company_id.resource_calendar_id.tz or\
|
||||
'UTC'
|
||||
|
||||
def _get_tz_batch(self):
|
||||
# Finds the first valid timezone in his tz, his work hours tz,
|
||||
# the company calendar tz or UTC
|
||||
# Returns a dict {employee_id: tz}
|
||||
return {emp.id: emp._get_tz() for emp in self}
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Business Methods
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import report
|
||||
|
||||
@@ -21,6 +21,8 @@ actions(Check in/Check out) performed by them.
|
||||
'security/hr_attendance_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/hr_attendance_view.xml',
|
||||
'views/hr_attendance_overtime_view.xml',
|
||||
'report/hr_attendance_report_views.xml',
|
||||
'views/hr_department_view.xml',
|
||||
'views/hr_employee_view.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
|
||||
@@ -16,74 +16,74 @@
|
||||
</record>
|
||||
|
||||
<record id="attendance_root1" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 08:00:00')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 12:00:00')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 08:00:24')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 12:01:33')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance_root2" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 13:00:00')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 18:00:00')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 13:02:58')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1, days=-1)).strftime('%Y-%m-%d 18:09:22')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance1" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-01 08:21')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-01 11:51')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-01 15:51')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance2" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-02 12:47')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-02 19:53')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-02 08:47')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-02 15:53')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance3" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-03 07:32')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-03 12:32')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-03 08:32')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-03 15:22')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance4" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-04 14:01')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-04 17:21')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-04 08:01')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-04 16:21')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance5" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-05 09:10')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-05 12:42')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-05 10:10')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-05 14:42')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance6" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-06 13:10')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-06 18:34')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-06 10:10')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-06 17:34')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance7" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-07 08:21')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-07 18:21')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-07 17:29')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance8" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-08 08:21')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-08 12:54')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-08 09:21')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-08 14:54')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance9" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-09 13:32')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-09 19:31')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-09 10:32')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-09 17:31')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
|
||||
<record id="attendance10" model="hr.attendance">
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-10 08:00')" name="check_in"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-10 09:00')" name="check_out"/>
|
||||
<field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-10 17:00')" name="check_out"/>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from . import res_config_settings
|
||||
from . import hr_attendance
|
||||
from . import hr_attendance_overtime
|
||||
from . import hr_employee
|
||||
from . import ir_ui_menu
|
||||
from . import res_company
|
||||
from . import res_users
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# -*- 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 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
|
||||
|
||||
|
||||
class HrAttendance(models.Model):
|
||||
@@ -99,6 +106,192 @@ class HrAttendance(models.Model):
|
||||
'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
|
||||
# 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()))
|
||||
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 _update_overtime(self, employee_attendance_dates=None):
|
||||
if employee_attendance_dates is None:
|
||||
employee_attendance_dates = self._get_attendances_dates()
|
||||
|
||||
overtime_to_unlink = self.env['hr.attendance.overtime']
|
||||
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
|
||||
emp_tz = pytz.timezone(emp._get_tz())
|
||||
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.resource_calendar_id._attendance_intervals_batch(
|
||||
start, stop, emp.resource_id
|
||||
)[emp.resource_id.id]
|
||||
# Substract Global Leaves
|
||||
expected_attendances -= emp.resource_calendar_id._leave_intervals_batch(start, stop, None)[False]
|
||||
|
||||
# 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 = emp_tz.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 = emp_tz.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()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
res._update_overtime()
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
attendances_dates = self._get_attendances_dates()
|
||||
super(HrAttendance, self).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)
|
||||
|
||||
def unlink(self):
|
||||
attendances_dates = self._get_attendances_dates()
|
||||
super(HrAttendance, self).unlink()
|
||||
self._update_overtime(attendances_dates)
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self):
|
||||
raise exceptions.UserError(_('You cannot duplicate an attendance.'))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class HrAttendanceOvertime(models.Model):
|
||||
_name = "hr.attendance.overtime"
|
||||
_description = "Attendance Overtime"
|
||||
_rec_name = 'employee_id'
|
||||
|
||||
def _default_employee(self):
|
||||
return self.env.user.employee_id
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string="Employee", default=_default_employee,
|
||||
required=True, ondelete='cascade', index=True)
|
||||
company_id = fields.Many2one(related='employee_id.company_id')
|
||||
|
||||
date = fields.Date(string='Day')
|
||||
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)
|
||||
|
||||
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))
|
||||
@@ -2,10 +2,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import models, fields, api, exceptions, _, SUPERUSER_ID
|
||||
from odoo import models, fields, api, exceptions, _
|
||||
from odoo.tools import float_round
|
||||
|
||||
|
||||
class HrEmployeeBase(models.AbstractModel):
|
||||
@@ -19,6 +19,16 @@ class HrEmployeeBase(models.AbstractModel):
|
||||
hours_last_month = fields.Float(compute='_compute_hours_last_month')
|
||||
hours_today = fields.Float(compute='_compute_hours_today')
|
||||
hours_last_month_display = fields.Char(compute='_compute_hours_last_month')
|
||||
overtime_ids = fields.One2many('hr.attendance.overtime', 'employee_id')
|
||||
total_overtime = fields.Float(compute='_compute_total_overtime')
|
||||
|
||||
@api.depends('overtime_ids.duration', 'attendance_ids')
|
||||
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
|
||||
|
||||
@api.depends('user_id.im_status', 'attendance_state')
|
||||
def _compute_presence_state(self):
|
||||
@@ -136,6 +146,7 @@ class HrEmployeeBase(models.AbstractModel):
|
||||
else:
|
||||
modified_attendance = employee._attendance_action_change()
|
||||
action_message['attendance'] = modified_attendance.read()[0]
|
||||
action_message['total_overtime'] = employee.total_overtime
|
||||
return {'action': action_message}
|
||||
|
||||
def _attendance_action_change(self):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
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 write(self, vals):
|
||||
search_domain = False # Overtime to generate
|
||||
delete_domain = False # Overtime to delete
|
||||
|
||||
overtime_enabled_companies = self.filtered('hr_attendance_overtime')
|
||||
# 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
|
||||
|
||||
start_date = vals.get('hr_attendance_overtime') and vals.get('overtime_start_date')
|
||||
# Also recompute if the threshold have changed
|
||||
if 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 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 company.overtime_start_date < start_date:
|
||||
delete_domain = OR([delete_domain, [
|
||||
('company_id', '=', company.id),
|
||||
('date', '<', start_date)]])
|
||||
|
||||
res = super().write(vals)
|
||||
if delete_domain:
|
||||
self.env['hr.attendance.overtime'].search(delete_domain).unlink()
|
||||
if search_domain:
|
||||
self.env['hr.attendance'].search(search_domain)._update_overtime()
|
||||
|
||||
return res
|
||||
@@ -1,11 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
group_attendance_use_pin = fields.Boolean(string='Employee PIN',
|
||||
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)
|
||||
overtime_company_threshold = fields.Integer(
|
||||
string="Tolerance Time In Favor Of Company", readonly=False)
|
||||
overtime_employee_threshold = fields.Integer(
|
||||
string="Tolerance Time In Favor Of Employee", 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,
|
||||
})
|
||||
return res
|
||||
|
||||
def set_values(self):
|
||||
super(ResConfigSettings, self).set_values()
|
||||
company = self.env.company
|
||||
# Done this way to have all the values written at the same time,
|
||||
# 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})
|
||||
|
||||
@@ -12,6 +12,7 @@ class User(models.Model):
|
||||
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):
|
||||
@@ -20,5 +21,6 @@ class User(models.Model):
|
||||
'hours_last_month_display',
|
||||
'attendance_state',
|
||||
'last_check_in',
|
||||
'last_check_out'
|
||||
'last_check_out',
|
||||
'total_overtime'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_attendance_report
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HRAttendanceReport(models.Model):
|
||||
_name = "hr.attendance.report"
|
||||
_description = "Attendance Statistics"
|
||||
_auto = False
|
||||
|
||||
department_id = fields.Many2one('hr.department', string="Department", readonly=True)
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", readonly=True)
|
||||
check_in = fields.Date("Check In", readonly=True)
|
||||
worked_hours = fields.Float("Hours Worked", readonly=True)
|
||||
overtime_hours = fields.Float("Extra Hours", readonly=True)
|
||||
|
||||
def init(self):
|
||||
self.env.cr.execute("""
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
(
|
||||
SELECT
|
||||
hra.id,
|
||||
department_id,
|
||||
hra.employee_id,
|
||||
hra.check_in,
|
||||
hra.worked_hours,
|
||||
coalesce(ot.duration, 0) as overtime_hours
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
row_number() over (partition by employee_id, CAST(check_in AS DATE)) as ot_check,
|
||||
employee_id,
|
||||
CAST(check_in as DATE) as check_in,
|
||||
worked_hours
|
||||
FROM
|
||||
hr_attendance
|
||||
) as hra
|
||||
LEFT JOIN
|
||||
hr_employee
|
||||
ON hr_employee.id = hra.employee_id
|
||||
LEFT JOIN
|
||||
hr_attendance_overtime ot
|
||||
ON hra.ot_check = 1
|
||||
AND ot.employee_id = hra.employee_id
|
||||
AND ot.date = hra.check_in
|
||||
AND ot.adjustment = FALSE
|
||||
)
|
||||
)
|
||||
""" % (self._table))
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="hr_attendance_report_view_search" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.report.view.search</field>
|
||||
<field name="model">hr.attendance.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="HR Attendance Search">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id" operator="child_of"/>
|
||||
<filter name="check_in" string="Check In" date="check_in"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employee" name="groupby_employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Check In" name="groupby_check_in" context="{'group_by': 'check_in'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_view_pivot" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.report.view.pivot</field>
|
||||
<field name="model">hr.attendance.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Attendance" disable_linking="1">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="check_in" type="col"/>
|
||||
<field name="worked_hours" type="measure"/>
|
||||
<field name="overtime_hours" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_view_graph" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.report.view.graph</field>
|
||||
<field name="model">hr.attendance.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Attendance Statistics" stacked="False" disable_linking="1">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="check_in" type="col"/>
|
||||
<field name="overtime_hours" type="measure" />
|
||||
<field name="worked_hours" type="measure" />
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_action" model="ir.actions.act_window">
|
||||
<field name="name">Attendance Analysis</field>
|
||||
<field name="res_model">hr.attendance.report</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="search_view_id" ref="hr_attendance_report_view_search"/>
|
||||
<field name="context">{'group_by': ['check_in:day', 'employee_id']}</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_action_filtered" model="ir.actions.act_window">
|
||||
<field name="name">Attendance Analysis</field>
|
||||
<field name="res_model">hr.attendance.report</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="search_view_id" ref="hr_attendance_report_view_search"/>
|
||||
<field name="context">{
|
||||
'group_by': ['check_in:day', 'employee_id'],
|
||||
'search_default_department_id': [active_id]}
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_hr_attendance_report"
|
||||
name="Reporting"
|
||||
sequence="30"
|
||||
parent="hr_attendance.menu_hr_attendance_root"
|
||||
action="hr_attendance_report_action"
|
||||
groups="hr_attendance.group_hr_attendance_user"/>
|
||||
</odoo>
|
||||
@@ -83,5 +83,30 @@
|
||||
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Overtime -->
|
||||
<record id="hr_attendance_overtime_rule_employee_company" model="ir.rule">
|
||||
<field name="name">Employee multi company rule</field>
|
||||
<field name="model_id" ref="model_hr_attendance_overtime"/>
|
||||
<field name="global" eval="True"/>
|
||||
<field name="domain_force">['|',('employee_id.company_id','=',False),('employee_id.company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_overtime_manager" model="ir.rule">
|
||||
<field name="name">attendance officer: full access</field>
|
||||
<field name="model_id" ref="model_hr_attendance_overtime"/>
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_overtime_employee" model="ir.rule">
|
||||
<field name="name">user: read and modify own attendance only</field>
|
||||
<field name="model_id" ref="model_hr_attendance_overtime"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_hr_attendance_user,hr.attendance.user,model_hr_attendance,hr_attendance.group_hr_attendance_user,1,1,1,1
|
||||
access_hr_attendance_system_user,hr.attendance.system.user,model_hr_attendance,base.group_user,1,1,1,0
|
||||
access_hr_attendance_overtime_user,hr.attendance.overtime.user,model_hr_attendance_overtime,hr_attendance.group_hr_attendance_user,1,1,1,1
|
||||
access_hr_attendance_overtime_system_user,hr.attendance.overtime.system.user,model_hr_attendance_overtime,base.group_user,1,0,0,0
|
||||
access_hr_attendance_report_user,hr.attendance.report.user,model_hr_attendance_report,hr_attendance.group_hr_attendance_user,1,1,1,1
|
||||
|
||||
|
@@ -4,6 +4,7 @@ odoo.define('hr_attendance.greeting_message', function (require) {
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var core = require('web.core');
|
||||
var time = require('web.time');
|
||||
var field_utils = require('web.field_utils');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
@@ -52,6 +53,10 @@ var GreetingMessage = AbstractAction.extend({
|
||||
this.attendance.check_in_time = this.attendance.check_in && this.attendance.check_in.format(this.format_time);
|
||||
this.attendance.check_out_time = this.attendance.check_out && this.attendance.check_out.format(this.format_time);
|
||||
|
||||
// extra hours amount displayed in the greeting message template.
|
||||
this.total_overtime_float = action.total_overtime; // Used for comparison in template
|
||||
this.total_overtime = field_utils.format.float_time(this.total_overtime_float)
|
||||
|
||||
if (action.hours_today) {
|
||||
var duration = moment.duration(action.hours_today, "hours");
|
||||
this.hours_today = duration.hours() + ' hours, ' + duration.minutes() + ' minutes';
|
||||
|
||||
@@ -118,6 +118,18 @@
|
||||
Checked out at <b><t t-esc="widget.attendance.check_out_time"/></b>
|
||||
<br/><b><t t-esc="widget.hours_today"/></b>
|
||||
</div>
|
||||
<t t-if="widget.total_overtime_float > 0">
|
||||
<div class="alert alert-success h3 mt0" role="status">
|
||||
Total extra hours:
|
||||
<span t-esc="widget.total_overtime"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="widget.total_overtime_float < 0">
|
||||
<div class="alert alert-danger h3 mt0" role="status">
|
||||
Total extra hours:
|
||||
<span t-esc="widget.total_overtime"/>
|
||||
</div>
|
||||
</t>
|
||||
<h3 class="o_hr_attendance_random_message mb24"/>
|
||||
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
|
||||
</t>
|
||||
|
||||
@@ -24,6 +24,7 @@ QUnit.module('HR Attendance', {
|
||||
user_id: {string: 'user ID', type: 'integer'},
|
||||
barcode: {string:'barcode', type: 'integer'},
|
||||
hours_today: {string:'Hours today', type: 'float'},
|
||||
overtime: {string: 'Overtime', type: 'float'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_hr_attendance_constraints
|
||||
from . import test_hr_attendance_overtime
|
||||
from . import test_hr_attendance_process
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo.tests import new_test_user
|
||||
from odoo.tests.common import tagged, TransactionCase
|
||||
|
||||
@tagged('hr_attendance_overtime')
|
||||
class TestHrAttendanceOvertime(TransactionCase):
|
||||
""" Tests for overtime """
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env['res.company'].create({
|
||||
'name': 'SweatChopChop Inc.',
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': datetime(2021, 1, 1),
|
||||
'overtime_company_threshold': 10,
|
||||
'overtime_employee_threshold': 10,
|
||||
})
|
||||
self.env.company = self.company
|
||||
self.user = new_test_user(self.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance', company_id=self.company.id)
|
||||
self.employee = self.env['hr.employee'].create({
|
||||
'name': "Marie-Edouard De La Court",
|
||||
'user_id': self.user.id,
|
||||
'company_id': self.company.id,
|
||||
'tz': 'UTC',
|
||||
})
|
||||
self.other_employee = self.env['hr.employee'].create({
|
||||
'name': 'Yolanda',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.jpn_employee = self.env['hr.employee'].create({
|
||||
'name': 'Sacha',
|
||||
'company_id': self.company.id,
|
||||
'tz': 'Asia/Tokyo',
|
||||
})
|
||||
self.honolulu_employee = self.env['hr.employee'].create({
|
||||
'name': 'Susan',
|
||||
'company_id': self.company.id,
|
||||
'tz': 'Pacific/Honolulu',
|
||||
})
|
||||
|
||||
def test_overtime_company_settings(self):
|
||||
self.company.hr_attendance_overtime = False
|
||||
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 8, 0),
|
||||
'check_out': datetime(2021, 1, 4, 20, 0)
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search_count([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 4))])
|
||||
self.assertFalse(overtime, 'No overtime should be created')
|
||||
|
||||
self.company.write({
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': date(2021, 1, 1)
|
||||
})
|
||||
overtime = self.env['hr.attendance.overtime'].search_count([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 4))])
|
||||
self.assertTrue(overtime, 'Overtime should be created')
|
||||
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2020, 12, 30, 8, 0),
|
||||
'check_out': datetime(2020, 12, 30, 20, 0)
|
||||
})
|
||||
overtime = self.env['hr.attendance.overtime'].search_count([('employee_id', '=', self.employee.id), ('date', '=', date(2020, 12, 30))])
|
||||
self.assertFalse(overtime, 'No overtime should be created before the start date')
|
||||
|
||||
def test_simple_overtime(self):
|
||||
checkin_am = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 8, 0)
|
||||
})
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.other_employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 8, 0),
|
||||
'check_out': datetime(2021, 1, 4, 22, 0)
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 4))])
|
||||
self.assertFalse(overtime, 'No overtime record should exist for that employee')
|
||||
|
||||
checkin_am.write({'check_out': datetime(2021, 1, 4, 12, 0)})
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 4))])
|
||||
self.assertTrue(overtime, 'An overtime record should be created')
|
||||
self.assertEqual(overtime.duration, -4)
|
||||
|
||||
checkin_pm = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 13, 0)
|
||||
})
|
||||
self.assertEqual(overtime.duration, 0, 'Overtime duration should be 0 when an attendance has not been checked out.')
|
||||
checkin_pm.write({'check_out': datetime(2021, 1, 4, 18, 0)})
|
||||
self.assertTrue(overtime.exists(), 'Overtime should not be deleted')
|
||||
self.assertAlmostEqual(overtime.duration, 1)
|
||||
self.assertAlmostEqual(self.employee.total_overtime, 1)
|
||||
|
||||
def test_overtime_weekend(self):
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 2, 8, 0),
|
||||
'check_out': datetime(2021, 1, 2, 11, 0)
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 2))])
|
||||
self.assertTrue(overtime, 'Overtime should be created')
|
||||
self.assertEqual(overtime.duration, 3, 'Should have 3 hours of overtime')
|
||||
self.assertEqual(self.employee.total_overtime, 3, 'Should still have 3 hours of overtime')
|
||||
|
||||
def test_overtime_multiple(self):
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 2, 8, 0),
|
||||
'check_out': datetime(2021, 1, 2, 19, 0)
|
||||
})
|
||||
self.assertEqual(self.employee.total_overtime, 11)
|
||||
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 7, 0),
|
||||
'check_out': datetime(2021, 1, 4, 16, 0)
|
||||
})
|
||||
self.assertEqual(self.employee.total_overtime, 12)
|
||||
|
||||
attendance.unlink()
|
||||
self.assertEqual(self.employee.total_overtime, 1)
|
||||
|
||||
def test_overtime_change_employee(self):
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 7, 0),
|
||||
'check_out': datetime(2021, 1, 4, 18, 0)
|
||||
})
|
||||
self.assertEqual(self.employee.total_overtime, 3)
|
||||
self.assertEqual(self.other_employee.total_overtime, 0)
|
||||
|
||||
attendance.employee_id = self.other_employee.id
|
||||
self.assertEqual(self.other_employee.total_overtime, 3)
|
||||
self.assertEqual(self.employee.total_overtime, 0)
|
||||
|
||||
def test_overtime_far_timezones(self):
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.jpn_employee.id,
|
||||
# Since dates have to be stored in utc these are the tokyo timezone times for 7-18 (UTC+9)
|
||||
'check_in': datetime(2021, 1, 3, 22, 0),
|
||||
'check_out': datetime(2021, 1, 4, 9, 0),
|
||||
})
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.honolulu_employee.id,
|
||||
# Same but for alaskan times (UTC-10)
|
||||
'check_in': datetime(2021, 1, 4, 17, 0),
|
||||
'check_out': datetime(2021, 1, 5, 4, 0),
|
||||
})
|
||||
self.assertEqual(self.jpn_employee.total_overtime, 3)
|
||||
self.assertEqual(self.honolulu_employee.total_overtime, 3)
|
||||
|
||||
def test_overtime_unclosed(self):
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 8, 0),
|
||||
})
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertFalse(overtime, 'Overtime entry should not exist at this point.')
|
||||
# Employees goes to eat
|
||||
attendance.write({
|
||||
'check_out': datetime(2021, 1, 4, 12, 0),
|
||||
})
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertTrue(overtime, 'An overtime entry should have been created.')
|
||||
self.assertEqual(overtime.duration, -4, 'User still has to work the afternoon.')
|
||||
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 13, 0),
|
||||
})
|
||||
self.assertEqual(overtime.duration, 0, 'Overtime entry has been reset due to an unclosed attendance.')
|
||||
|
||||
def test_overtime_company_threshold(self):
|
||||
self.env['hr.attendance'].create([
|
||||
{
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 7, 55),
|
||||
'check_out': datetime(2021, 1, 4, 12, 0),
|
||||
},
|
||||
{
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 13, 0),
|
||||
'check_out': datetime(2021, 1, 4, 17, 5),
|
||||
}
|
||||
])
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertFalse(overtime, 'No overtime should be counted because of the threshold.')
|
||||
|
||||
self.company.write({
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': date(2021, 1, 1),
|
||||
'overtime_company_threshold': 4,
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertTrue(overtime, 'Overtime entry should exist since the threshold has been lowered.')
|
||||
self.assertAlmostEqual(overtime.duration, 10 / 60, msg='Overtime should be equal to 10 minutes.')
|
||||
|
||||
def test_overtime_employee_threshold(self):
|
||||
self.env['hr.attendance'].create([
|
||||
{
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 8, 5),
|
||||
'check_out': datetime(2021, 1, 4, 12, 0),
|
||||
},
|
||||
{
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 13, 0),
|
||||
'check_out': datetime(2021, 1, 4, 16, 55),
|
||||
}
|
||||
])
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertFalse(overtime, 'No overtime should be counted because of the threshold.')
|
||||
|
||||
self.company.write({
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': date(2021, 1, 1),
|
||||
'overtime_employee_threshold': 4,
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertTrue(overtime, 'Overtime entry should exist since the threshold has been lowered.')
|
||||
self.assertAlmostEqual(overtime.duration, -(10 / 60), msg='Overtime should be equal to -10 minutes.')
|
||||
|
||||
def test_overtime_both_threshold(self):
|
||||
self.env['hr.attendance'].create([
|
||||
{
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 8, 5),
|
||||
'check_out': datetime(2021, 1, 4, 12, 0),
|
||||
},
|
||||
{
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 13, 0),
|
||||
'check_out': datetime(2021, 1, 4, 17, 5),
|
||||
}
|
||||
])
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertFalse(overtime, 'No overtime should be counted because of the threshold.')
|
||||
|
||||
self.company.write({
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': date(2021, 1, 1),
|
||||
'overtime_employee_threshold': 4,
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertTrue(overtime, 'Overtime entry should exist since the employee threshold has been lowered.')
|
||||
self.assertAlmostEqual(overtime.duration, -(5 / 60), msg='Overtime should be equal to -5 minutes.')
|
||||
|
||||
self.company.write({
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': date(2021, 1, 1),
|
||||
'overtime_company_threshold': 4,
|
||||
})
|
||||
|
||||
overtime = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id)])
|
||||
self.assertFalse(overtime, 'Overtime entry should be unlinked since both overtime cancel each other.')
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_attendance_overtime_tree" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.overtime.tree</field>
|
||||
<field name="model">hr.attendance.overtime</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree edit="0">
|
||||
<field name="date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="duration" widget="float_time"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_attendance_overtime_search" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.overtime.search</field>
|
||||
<field name="model">hr.attendance.overtime</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="employee_id"/>
|
||||
<field name="duration" filter_domain="[('duration', '>=', self)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_overtime_action" model="ir.actions.act_window">
|
||||
<field name="name">Extra Hours</field>
|
||||
<field name="res_model">hr.attendance.overtime</field>
|
||||
<field name="view_mode">tree</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -107,30 +107,6 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_view_pivot" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.pivot</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Attendance" sample="1">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="check_in" type="col"/>
|
||||
<field name="worked_hours" type="measure" widget="float_time"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_view_graph" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.graph</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Attendance" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="check_in"/>
|
||||
<field name="worked_hours" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- actions -->
|
||||
|
||||
<record id="hr_attendance_action" model="ir.actions.act_window">
|
||||
@@ -176,34 +152,6 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_graph" model="ir.actions.act_window">
|
||||
<field name="name">Attendance Analysis</field>
|
||||
<field name="res_model">hr.attendance</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="view_id"></field> <!-- force empty -->
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No data yet!
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_graph_filtered" model="ir.actions.act_window">
|
||||
<field name="name">Attendance Analysis</field>
|
||||
<field name="res_model">hr.attendance</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="context">{
|
||||
'search_default_department_id': [active_id],
|
||||
'default_department_id': active_id}
|
||||
</field>
|
||||
<field name="view_id"></field> <!-- force empty -->
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No data yet!
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_kiosk_mode" model="ir.actions.client">
|
||||
<field name="name">Attendances</field>
|
||||
<field name="tag">hr_attendance_kiosk_mode</field>
|
||||
@@ -232,6 +180,4 @@
|
||||
<menuitem id="menu_hr_attendance_kiosk_no_user_mode" name="Kiosk Mode" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_kiosk" action="hr_attendance_action_kiosk_mode"/>
|
||||
|
||||
<menuitem id="menu_hr_attendance_view_attendances" name="Attendances" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_user" action="hr_attendance_action"/>
|
||||
|
||||
<menuitem id="menu_hr_attendance_report" name="Reporting" parent="menu_hr_attendance_root" sequence="30" groups="hr_attendance.group_hr_attendance_user" action="hr_attendance_action_graph"/>
|
||||
</odoo>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--Hr Department Inherit Kanban view-->
|
||||
<record id="hr_department_view_kanban" model="ir.ui.view">
|
||||
<field name="name">hr.department.kanban.inherit</field>
|
||||
<field name="model">hr.department</field>
|
||||
@@ -8,13 +7,11 @@
|
||||
<field name="groups_id" eval="[(4,ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
<field name="arch" type="xml">
|
||||
<data>
|
||||
|
||||
<xpath expr="//div[hasclass('o_kanban_manage_reports')]" position="inside">
|
||||
<a role="menuitem" class="dropdown-item" name="%(hr_attendance_action_graph_filtered)d" type="action">
|
||||
<a role="menuitem" class="dropdown-item" name="%(hr_attendance_report_action_filtered)d" type="action">
|
||||
Attendances
|
||||
</a>
|
||||
</xpath>
|
||||
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="%(hr_attendance_overtime_action)d"
|
||||
class="oe_stat_button"
|
||||
icon="fa-history"
|
||||
type="action"
|
||||
attrs="{'invisible': [('total_overtime', '=', 0.0)]}"
|
||||
context="{'search_default_employee_id': active_id}">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value text-success" attrs="{'invisible': [('total_overtime', '<', 0)]}">
|
||||
<field name="total_overtime" widget="float_time"/>
|
||||
</span>
|
||||
<span class="o_stat_value text-danger" attrs="{'invisible': [('total_overtime', '>=', 0)]}">
|
||||
<field name="total_overtime" widget="float_time"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Extra Hours</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
@@ -108,6 +124,26 @@
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="%(hr_attendance_overtime_action)d"
|
||||
class="oe_stat_button"
|
||||
icon="fa-history"
|
||||
type="action"
|
||||
attrs="{'invisible': [('total_overtime', '=', 0.0)]}"
|
||||
context="{'search_default_employee_id': employee_ids}"
|
||||
groups="base.group_user"
|
||||
help="Amount of extra hours">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value text-success" attrs="{'invisible': [('total_overtime', '<', 0)]}">
|
||||
<field name="total_overtime" widget="float_time"/>
|
||||
</span>
|
||||
<span class="o_stat_value text-danger" attrs="{'invisible': [('total_overtime', '>=', 0)]}">
|
||||
<field name="total_overtime" widget="float_time"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Extra Hours</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -120,6 +156,7 @@
|
||||
<kanban class="o_hr_employee_attendance_kanban" create="false">
|
||||
<field name="attendance_state"/>
|
||||
<field name="hours_today"/>
|
||||
<field name="total_overtime"/>
|
||||
<field name="id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority" eval="80"/>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[hasclass('settings')]" position="inside">
|
||||
<div class="app_settings_block" data-string="Attendances" string="Attendances" data-key="hr_attendance" groups="hr_attendance.group_hr_attendance_manager">
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[hasclass('settings')]" position="inside">
|
||||
<div class="app_settings_block" data-string="Attendances" string="Attendances" data-key="hr_attendance" groups="hr_attendance.group_hr_attendance_manager">
|
||||
<h2>Check-In/Out</h2>
|
||||
<div class="row mt16 o_settings_container" name="pincode_setting_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box" title="Set PIN codes in the employee detail form (in HR Settings tab).">
|
||||
@@ -22,6 +22,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Extra Hours</h2>
|
||||
<div class="row mt16 o_settings_container" name="overtime_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane" title="Activate the count of employees' extra hours.">
|
||||
<field name="hr_attendance_overtime"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." role="img" aria-label="Values set here are company-specific." groups="base.group_multi_company"/>
|
||||
<label for="hr_attendance_overtime" class="o_form_label">Count of Extra Hours</label>
|
||||
<div class="text-muted">
|
||||
Compare attendance with working hours set on employee.
|
||||
</div>
|
||||
<div class="mt16" attrs="{'invisible': [('hr_attendance_overtime', '=', False)],
|
||||
'required': [('hr_attendance_overtime', '=', True)]}">
|
||||
<div class="mt16 row" title="Count of extra hours is considered from this date. Potential extra hours prior to this date are not considered.">
|
||||
<label for="overtime_start_date" string="Start from" class="col-3 col-lg-3 o_light_label"/>
|
||||
<field name="overtime_start_date" class="col-lg-3 p-0" attrs="{'required': [('hr_attendance_overtime', '=', True)]}" />
|
||||
</div>
|
||||
<br/>
|
||||
<label for="overtime_company_threshold" class="o_form_label">
|
||||
Tolerance Time In Favor Of Company
|
||||
</label>
|
||||
<div class="text-muted">
|
||||
Allow a period of time (around working hours) where extra time will not be counted, in benefit of the company
|
||||
</div>
|
||||
<span>Time Period </span><field name="overtime_company_threshold" class="text-center oe_inline"
|
||||
attrs="{'required': [('hr_attendance_overtime', '=', True)]}"/><span> Minutes</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<label for="overtime_employee_threshold" class="o_form_label">
|
||||
Tolerance Time In Favor Of Employee
|
||||
</label>
|
||||
<div class="text-muted">
|
||||
Allow a period of time (around working hours) where extra time will not be deducted, in benefit of the employee
|
||||
</div>
|
||||
<span>Time Period </span><field name="overtime_employee_threshold" class="text-center oe_inline"
|
||||
attrs="{'required': [('hr_attendance_overtime', '=', True)]}"/><span> Minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -251,15 +251,18 @@ class HolidaysType(models.Model):
|
||||
@api.model
|
||||
def get_days_all_request(self):
|
||||
leave_types = sorted(self.search([]).filtered(lambda x: x.virtual_remaining_leaves or x.max_leaves), key=self._model_sorting_key, reverse=True)
|
||||
return [(lt.name, {
|
||||
'remaining_leaves': ('%.2f' % lt.remaining_leaves).rstrip('0').rstrip('.'),
|
||||
'virtual_remaining_leaves': ('%.2f' % lt.virtual_remaining_leaves).rstrip('0').rstrip('.'),
|
||||
'max_leaves': ('%.2f' % lt.max_leaves).rstrip('0').rstrip('.'),
|
||||
'leaves_taken': ('%.2f' % lt.leaves_taken).rstrip('0').rstrip('.'),
|
||||
'virtual_leaves_taken': ('%.2f' % lt.virtual_leaves_taken).rstrip('0').rstrip('.'),
|
||||
'request_unit': lt.request_unit,
|
||||
}, lt.allocation_type, lt.validity_stop, lt.id)
|
||||
for lt in leave_types]
|
||||
return [lt._get_days_request() for lt in leave_types]
|
||||
|
||||
def _get_days_request(self):
|
||||
self.ensure_one()
|
||||
return (self.name, {
|
||||
'remaining_leaves': ('%.2f' % self.remaining_leaves).rstrip('0').rstrip('.'),
|
||||
'virtual_remaining_leaves': ('%.2f' % self.virtual_remaining_leaves).rstrip('0').rstrip('.'),
|
||||
'max_leaves': ('%.2f' % self.max_leaves).rstrip('0').rstrip('.'),
|
||||
'leaves_taken': ('%.2f' % self.leaves_taken).rstrip('0').rstrip('.'),
|
||||
'virtual_leaves_taken': ('%.2f' % self.virtual_leaves_taken).rstrip('0').rstrip('.'),
|
||||
'request_unit': self.request_unit,
|
||||
}, self.allocation_type, self.validity_stop, self.id)
|
||||
|
||||
def _get_contextual_employee_id(self):
|
||||
if 'employee_id' in self._context:
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
|
||||
self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2))
|
||||
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
|
||||
|
||||
with self.assertQueryCount(__system__=96, admin=97):
|
||||
with self.assertQueryCount(__system__=96, admin=100):
|
||||
leave.action_validate()
|
||||
leave.action_refuse()
|
||||
|
||||
@@ -40,7 +40,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
|
||||
def test_performance_leave_write(self):
|
||||
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
|
||||
|
||||
with self.assertQueryCount(__system__=20, admin=20):
|
||||
with self.assertQueryCount(__system__=24, admin=33):
|
||||
leave.date_to = datetime(2018, 1, 1, 19, 0)
|
||||
leave.action_refuse()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user