From 9e6d2da50480b2df7abe01ae0d60260511194b4f Mon Sep 17 00:00:00 2001 From: Kevin Baptiste Date: Tue, 26 Jan 2021 11:05:23 +0100 Subject: [PATCH] [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 --- addons/hr/models/hr_employee.py | 15 + addons/hr_attendance/__init__.py | 1 + addons/hr_attendance/__manifest__.py | 2 + .../hr_attendance/data/hr_attendance_demo.xml | 42 +-- addons/hr_attendance/models/__init__.py | 2 + addons/hr_attendance/models/hr_attendance.py | 193 +++++++++++++ .../models/hr_attendance_overtime.py | 32 +++ addons/hr_attendance/models/hr_employee.py | 15 +- addons/hr_attendance/models/res_company.py | 58 ++++ .../models/res_config_settings.py | 39 ++- addons/hr_attendance/models/res_users.py | 4 +- addons/hr_attendance/report/__init__.py | 4 + .../report/hr_attendance_report.py | 50 ++++ .../report/hr_attendance_report_views.xml | 71 +++++ .../security/hr_attendance_security.xml | 25 ++ .../security/ir.model.access.csv | 3 + .../static/src/js/greeting_message.js | 5 + .../static/src/xml/attendance.xml | 12 + .../static/tests/hr_attendance_tests.js | 1 + addons/hr_attendance/tests/__init__.py | 1 + .../tests/test_hr_attendance_overtime.py | 266 ++++++++++++++++++ .../views/hr_attendance_overtime_view.xml | 31 ++ .../views/hr_attendance_view.xml | 54 ---- .../views/hr_department_view.xml | 5 +- .../hr_attendance/views/hr_employee_view.xml | 37 +++ .../views/res_config_settings_views.xml | 47 +++- addons/hr_holidays/models/hr_leave_type.py | 21 +- .../tests/test_performance.py | 4 +- 28 files changed, 942 insertions(+), 98 deletions(-) create mode 100644 addons/hr_attendance/models/hr_attendance_overtime.py create mode 100644 addons/hr_attendance/models/res_company.py create mode 100644 addons/hr_attendance/report/__init__.py create mode 100644 addons/hr_attendance/report/hr_attendance_report.py create mode 100644 addons/hr_attendance/report/hr_attendance_report_views.xml create mode 100644 addons/hr_attendance/tests/test_hr_attendance_overtime.py create mode 100644 addons/hr_attendance/views/hr_attendance_overtime_view.xml diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index 104a82144b5..38ef9c96c26 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -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 # --------------------------------------------------------- diff --git a/addons/hr_attendance/__init__.py b/addons/hr_attendance/__init__.py index 7d34c7c054a..5905766d867 100644 --- a/addons/hr_attendance/__init__.py +++ b/addons/hr_attendance/__init__.py @@ -3,3 +3,4 @@ from . import controllers from . import models +from . import report diff --git a/addons/hr_attendance/__manifest__.py b/addons/hr_attendance/__manifest__.py index 3ccc8093dab..0daa9b331c5 100644 --- a/addons/hr_attendance/__manifest__.py +++ b/addons/hr_attendance/__manifest__.py @@ -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', diff --git a/addons/hr_attendance/data/hr_attendance_demo.xml b/addons/hr_attendance/data/hr_attendance_demo.xml index 278c72798b5..d946af0ccfd 100644 --- a/addons/hr_attendance/data/hr_attendance_demo.xml +++ b/addons/hr_attendance/data/hr_attendance_demo.xml @@ -16,74 +16,74 @@ - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - + diff --git a/addons/hr_attendance/models/__init__.py b/addons/hr_attendance/models/__init__.py index ac648b8357f..ebbcbe2deb6 100644 --- a/addons/hr_attendance/models/__init__.py +++ b/addons/hr_attendance/models/__init__.py @@ -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 diff --git a/addons/hr_attendance/models/hr_attendance.py b/addons/hr_attendance/models/hr_attendance.py index 5e91d90b886..c7e93c186d5 100644 --- a/addons/hr_attendance/models/hr_attendance.py +++ b/addons/hr_attendance/models/hr_attendance.py @@ -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.')) diff --git a/addons/hr_attendance/models/hr_attendance_overtime.py b/addons/hr_attendance/models/hr_attendance_overtime.py new file mode 100644 index 00000000000..02eb362d286 --- /dev/null +++ b/addons/hr_attendance/models/hr_attendance_overtime.py @@ -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)) diff --git a/addons/hr_attendance/models/hr_employee.py b/addons/hr_attendance/models/hr_employee.py index 0f65f37251e..87ddfad7a7a 100644 --- a/addons/hr_attendance/models/hr_employee.py +++ b/addons/hr_attendance/models/hr_employee.py @@ -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): diff --git a/addons/hr_attendance/models/res_company.py b/addons/hr_attendance/models/res_company.py new file mode 100644 index 00000000000..e418f756594 --- /dev/null +++ b/addons/hr_attendance/models/res_company.py @@ -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 diff --git a/addons/hr_attendance/models/res_config_settings.py b/addons/hr_attendance/models/res_config_settings.py index 5e67d2e50df..7516aec2b0f 100644 --- a/addons/hr_attendance/models/res_config_settings.py +++ b/addons/hr_attendance/models/res_config_settings.py @@ -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}) diff --git a/addons/hr_attendance/models/res_users.py b/addons/hr_attendance/models/res_users.py index cc499cdb125..69a9e63054b 100644 --- a/addons/hr_attendance/models/res_users.py +++ b/addons/hr_attendance/models/res_users.py @@ -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' ] diff --git a/addons/hr_attendance/report/__init__.py b/addons/hr_attendance/report/__init__.py new file mode 100644 index 00000000000..3588798d571 --- /dev/null +++ b/addons/hr_attendance/report/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_attendance_report diff --git a/addons/hr_attendance/report/hr_attendance_report.py b/addons/hr_attendance/report/hr_attendance_report.py new file mode 100644 index 00000000000..7b1385031ce --- /dev/null +++ b/addons/hr_attendance/report/hr_attendance_report.py @@ -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)) diff --git a/addons/hr_attendance/report/hr_attendance_report_views.xml b/addons/hr_attendance/report/hr_attendance_report_views.xml new file mode 100644 index 00000000000..09e8b6198b2 --- /dev/null +++ b/addons/hr_attendance/report/hr_attendance_report_views.xml @@ -0,0 +1,71 @@ + + + + hr.attendance.report.view.search + hr.attendance.report + + + + + + + + + + + + + + + hr.attendance.report.view.pivot + hr.attendance.report + + + + + + + + + + + + hr.attendance.report.view.graph + hr.attendance.report + + + + + + + + + + + + Attendance Analysis + hr.attendance.report + graph,pivot + + {'group_by': ['check_in:day', 'employee_id']} + + + + Attendance Analysis + hr.attendance.report + graph,pivot + + { + 'group_by': ['check_in:day', 'employee_id'], + 'search_default_department_id': [active_id]} + + + + + diff --git a/addons/hr_attendance/security/hr_attendance_security.xml b/addons/hr_attendance/security/hr_attendance_security.xml index 401f68899bb..da89fe7c437 100644 --- a/addons/hr_attendance/security/hr_attendance_security.xml +++ b/addons/hr_attendance/security/hr_attendance_security.xml @@ -83,5 +83,30 @@ + + + Employee multi company rule + + + ['|',('employee_id.company_id','=',False),('employee_id.company_id', 'in', company_ids)] + + + + attendance officer: full access + + [(1,'=',1)] + + + + + user: read and modify own attendance only + + [('employee_id.user_id', '=', user.id)] + + + + + + diff --git a/addons/hr_attendance/security/ir.model.access.csv b/addons/hr_attendance/security/ir.model.access.csv index 0c0cd4df5c8..f6e896f1405 100644 --- a/addons/hr_attendance/security/ir.model.access.csv +++ b/addons/hr_attendance/security/ir.model.access.csv @@ -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 diff --git a/addons/hr_attendance/static/src/js/greeting_message.js b/addons/hr_attendance/static/src/js/greeting_message.js index 51ff2682f1b..3c816087b36 100644 --- a/addons/hr_attendance/static/src/js/greeting_message.js +++ b/addons/hr_attendance/static/src/js/greeting_message.js @@ -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'; diff --git a/addons/hr_attendance/static/src/xml/attendance.xml b/addons/hr_attendance/static/src/xml/attendance.xml index 949566bddb7..d353702873a 100644 --- a/addons/hr_attendance/static/src/xml/attendance.xml +++ b/addons/hr_attendance/static/src/xml/attendance.xml @@ -118,6 +118,18 @@ Checked out at
+ +
+ Total extra hours: + +
+
+ +
+ Total extra hours: + +
+

+ @@ -108,6 +124,26 @@ + + + @@ -120,6 +156,7 @@ + diff --git a/addons/hr_attendance/views/res_config_settings_views.xml b/addons/hr_attendance/views/res_config_settings_views.xml index 3ae3aafefea..54d49173cec 100644 --- a/addons/hr_attendance/views/res_config_settings_views.xml +++ b/addons/hr_attendance/views/res_config_settings_views.xml @@ -5,9 +5,9 @@ res.config.settings - - -
+ + +

Check-In/Out

@@ -22,6 +22,47 @@
+

Extra Hours

+
+
+
+ +
+
+ + +
+ Compare attendance with working hours set on employee. +
+
+
+
+
+ +
+ Allow a period of time (around working hours) where extra time will not be counted, in benefit of the company +
+ Time Period Minutes +
+
+ +
+ Allow a period of time (around working hours) where extra time will not be deducted, in benefit of the employee +
+ Time Period Minutes +
+
+
+
diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index 8f67aa3e971..854ca2d7bb0 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -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: diff --git a/addons/hr_work_entry_holidays/tests/test_performance.py b/addons/hr_work_entry_holidays/tests/test_performance.py index b3f72f8ed64..615b0faf33c 100644 --- a/addons/hr_work_entry_holidays/tests/test_performance.py +++ b/addons/hr_work_entry_holidays/tests/test_performance.py @@ -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()