mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-27 03:12:05 +02:00
Initial commit: Hr packages
This commit is contained in:
commit
62531cd146
2820 changed files with 1432848 additions and 0 deletions
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_config_settings
|
||||
from . import hr_attendance
|
||||
from . import hr_attendance_overtime
|
||||
from . import hr_employee
|
||||
from . import hr_employee_public
|
||||
from . import ir_ui_menu
|
||||
from . import res_company
|
||||
from . import res_users
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
# -*- 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
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class HrAttendance(models.Model):
|
||||
_name = "hr.attendance"
|
||||
_description = "Attendance"
|
||||
_order = "check_in desc"
|
||||
|
||||
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)
|
||||
department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
|
||||
readonly=True)
|
||||
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True)
|
||||
check_out = fields.Datetime(string="Check Out")
|
||||
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
for attendance in self:
|
||||
if not attendance.check_out:
|
||||
result.append((attendance.id, _("%(empl_name)s from %(check_in)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'check_in': format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
}))
|
||||
else:
|
||||
result.append((attendance.id, _("%(empl_name)s from %(check_in)s to %(check_out)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'check_in': format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
'check_out': format_datetime(self.env, attendance.check_out, dt_format=False),
|
||||
}))
|
||||
return result
|
||||
|
||||
@api.depends('check_in', 'check_out')
|
||||
def _compute_worked_hours(self):
|
||||
for attendance in self:
|
||||
if attendance.check_out and attendance.check_in:
|
||||
delta = attendance.check_out - attendance.check_in
|
||||
attendance.worked_hours = delta.total_seconds() / 3600.0
|
||||
else:
|
||||
attendance.worked_hours = False
|
||||
|
||||
@api.constrains('check_in', 'check_out')
|
||||
def _check_validity_check_in_check_out(self):
|
||||
""" verifies if check_in is earlier than check_out. """
|
||||
for attendance in self:
|
||||
if attendance.check_in and attendance.check_out:
|
||||
if attendance.check_out < attendance.check_in:
|
||||
raise exceptions.ValidationError(_('"Check Out" time cannot be earlier than "Check In" time.'))
|
||||
|
||||
@api.constrains('check_in', 'check_out', 'employee_id')
|
||||
def _check_validity(self):
|
||||
""" Verifies the validity of the attendance record compared to the others from the same employee.
|
||||
For the same employee we must have :
|
||||
* maximum 1 "open" attendance record (without check_out)
|
||||
* no overlapping time slices with previous employee records
|
||||
"""
|
||||
for attendance in self:
|
||||
# we take the latest attendance before our check_in time and check it doesn't overlap with ours
|
||||
last_attendance_before_check_in = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', attendance.employee_id.id),
|
||||
('check_in', '<=', attendance.check_in),
|
||||
('id', '!=', attendance.id),
|
||||
], order='check_in desc', limit=1)
|
||||
if last_attendance_before_check_in and last_attendance_before_check_in.check_out and last_attendance_before_check_in.check_out > attendance.check_in:
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee was already checked in on %(datetime)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'datetime': format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
})
|
||||
|
||||
if not attendance.check_out:
|
||||
# if our attendance is "open" (no check_out), we verify there is no other "open" attendance
|
||||
no_check_out_attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', attendance.employee_id.id),
|
||||
('check_out', '=', False),
|
||||
('id', '!=', attendance.id),
|
||||
], order='check_in desc', limit=1)
|
||||
if no_check_out_attendances:
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee hasn't checked out since %(datetime)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'datetime': format_datetime(self.env, no_check_out_attendances.check_in, dt_format=False),
|
||||
})
|
||||
else:
|
||||
# we verify that the latest attendance with check_in time before our check_out time
|
||||
# is the same as the one before our check_in time computed before, otherwise it overlaps
|
||||
last_attendance_before_check_out = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', attendance.employee_id.id),
|
||||
('check_in', '<', attendance.check_out),
|
||||
('id', '!=', attendance.id),
|
||||
], order='check_in desc', limit=1)
|
||||
if last_attendance_before_check_out and last_attendance_before_check_in != last_attendance_before_check_out:
|
||||
raise exceptions.ValidationError(_("Cannot create new attendance record for %(empl_name)s, the employee was already checked in on %(datetime)s") % {
|
||||
'empl_name': attendance.employee_id.name,
|
||||
'datetime': format_datetime(self.env, last_attendance_before_check_out.check_in, dt_format=False),
|
||||
})
|
||||
|
||||
@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 _get_overtime_leave_domain(self):
|
||||
return []
|
||||
|
||||
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
|
||||
attendance_domain = []
|
||||
for attendance_date in attendance_dates:
|
||||
attendance_domain = OR([attendance_domain, [
|
||||
('check_in', '>=', attendance_date[0]), ('check_in', '<', attendance_date[0] + timedelta(hours=24)),
|
||||
]])
|
||||
attendance_domain = AND([[('employee_id', '=', emp.id)], attendance_domain])
|
||||
|
||||
# Attendances per LOCAL day
|
||||
attendances_per_day = defaultdict(lambda: self.env['hr.attendance'])
|
||||
all_attendances = self.env['hr.attendance'].search(attendance_domain)
|
||||
for attendance in all_attendances:
|
||||
check_in_day_start = attendance._get_day_start_and_day(attendance.employee_id, attendance.check_in)
|
||||
attendances_per_day[check_in_day_start[1]] += attendance
|
||||
|
||||
# As _attendance_intervals_batch and _leave_intervals_batch both take localized dates we need to localize those date
|
||||
start = pytz.utc.localize(min(attendance_dates, key=itemgetter(0))[0])
|
||||
stop = pytz.utc.localize(max(attendance_dates, key=itemgetter(0))[0] + timedelta(hours=24))
|
||||
|
||||
# Retrieve expected attendance intervals
|
||||
expected_attendances = emp._get_expected_attendances(start, stop, domain=AND([self._get_overtime_leave_domain(), [('company_id', 'in', [False, emp.company_id.id])]]))
|
||||
|
||||
# working_times = {date: [(start, stop)]}
|
||||
working_times = defaultdict(lambda: [])
|
||||
for expected_attendance in expected_attendances:
|
||||
# Exclude resource.calendar.attendance
|
||||
working_times[expected_attendance[0].date()].append(expected_attendance[:2])
|
||||
|
||||
overtimes = self.env['hr.attendance.overtime'].sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('date', 'in', [day_data[1] for day_data in attendance_dates]),
|
||||
('adjustment', '=', False),
|
||||
])
|
||||
|
||||
company_threshold = emp.company_id.overtime_company_threshold / 60.0
|
||||
employee_threshold = emp.company_id.overtime_employee_threshold / 60.0
|
||||
|
||||
for day_data in attendance_dates:
|
||||
attendance_date = day_data[1]
|
||||
attendances = attendances_per_day.get(attendance_date, self.browse())
|
||||
unfinished_shifts = attendances.filtered(lambda a: not a.check_out)
|
||||
overtime_duration = 0
|
||||
overtime_duration_real = 0
|
||||
# Overtime is not counted if any shift is not closed or if there are no attendances for that day,
|
||||
# this could happen when deleting attendances.
|
||||
if not unfinished_shifts and attendances:
|
||||
# The employee usually doesn't work on that day
|
||||
if not working_times[attendance_date]:
|
||||
# User does not have any resource_calendar_attendance for that day (week-end for example)
|
||||
overtime_duration = sum(attendances.mapped('worked_hours'))
|
||||
overtime_duration_real = overtime_duration
|
||||
# The employee usually work on that day
|
||||
else:
|
||||
# Compute start and end time for that day
|
||||
planned_start_dt, planned_end_dt = False, False
|
||||
planned_work_duration = 0
|
||||
for calendar_attendance in working_times[attendance_date]:
|
||||
planned_start_dt = min(planned_start_dt, calendar_attendance[0]) if planned_start_dt else calendar_attendance[0]
|
||||
planned_end_dt = max(planned_end_dt, calendar_attendance[1]) if planned_end_dt else calendar_attendance[1]
|
||||
planned_work_duration += (calendar_attendance[1] - calendar_attendance[0]).total_seconds() / 3600.0
|
||||
# Count time before, during and after 'working hours'
|
||||
pre_work_time, work_duration, post_work_time = 0, 0, 0
|
||||
|
||||
for attendance in attendances:
|
||||
# consider check_in as planned_start_dt if within threshold
|
||||
# if delta_in < 0: Checked in after supposed start of the day
|
||||
# if delta_in > 0: Checked in before supposed start of the day
|
||||
local_check_in = pytz.utc.localize(attendance.check_in)
|
||||
delta_in = (planned_start_dt - local_check_in).total_seconds() / 3600.0
|
||||
|
||||
# Started before or after planned date within the threshold interval
|
||||
if (delta_in > 0 and delta_in <= company_threshold) or\
|
||||
(delta_in < 0 and abs(delta_in) <= employee_threshold):
|
||||
local_check_in = planned_start_dt
|
||||
local_check_out = pytz.utc.localize(attendance.check_out)
|
||||
|
||||
# same for check_out as planned_end_dt
|
||||
delta_out = (local_check_out - planned_end_dt).total_seconds() / 3600.0
|
||||
# if delta_out < 0: Checked out before supposed start of the day
|
||||
# if delta_out > 0: Checked out after supposed start of the day
|
||||
|
||||
# Finised before or after planned date within the threshold interval
|
||||
if (delta_out > 0 and delta_out <= company_threshold) or\
|
||||
(delta_out < 0 and abs(delta_out) <= employee_threshold):
|
||||
local_check_out = planned_end_dt
|
||||
|
||||
# There is an overtime at the start of the day
|
||||
if local_check_in < planned_start_dt:
|
||||
pre_work_time += (min(planned_start_dt, local_check_out) - local_check_in).total_seconds() / 3600.0
|
||||
# Interval inside the working hours -> Considered as working time
|
||||
if local_check_in <= planned_end_dt and local_check_out >= planned_start_dt:
|
||||
work_duration += (min(planned_end_dt, local_check_out) - max(planned_start_dt, local_check_in)).total_seconds() / 3600.0
|
||||
# There is an overtime at the end of the day
|
||||
if local_check_out > planned_end_dt:
|
||||
post_work_time += (local_check_out - max(planned_end_dt, local_check_in)).total_seconds() / 3600.0
|
||||
|
||||
# Overtime within the planned work hours + overtime before/after work hours is > company threshold
|
||||
overtime_duration = work_duration - planned_work_duration
|
||||
if pre_work_time > company_threshold:
|
||||
overtime_duration += pre_work_time
|
||||
if post_work_time > company_threshold:
|
||||
overtime_duration += post_work_time
|
||||
# Global overtime including the thresholds
|
||||
overtime_duration_real = sum(attendances.mapped('worked_hours')) - planned_work_duration
|
||||
|
||||
overtime = overtimes.filtered(lambda o: o.date == attendance_date)
|
||||
if not float_is_zero(overtime_duration, 2) or unfinished_shifts:
|
||||
# Do not create if any attendance doesn't have a check_out, update if exists
|
||||
if unfinished_shifts:
|
||||
overtime_duration = 0
|
||||
if not overtime and overtime_duration:
|
||||
overtime_vals_list.append({
|
||||
'employee_id': emp.id,
|
||||
'date': attendance_date,
|
||||
'duration': overtime_duration,
|
||||
'duration_real': overtime_duration_real,
|
||||
})
|
||||
elif overtime:
|
||||
overtime.sudo().write({
|
||||
'duration': overtime_duration,
|
||||
'duration_real': overtime_duration
|
||||
})
|
||||
elif overtime:
|
||||
overtime_to_unlink |= overtime
|
||||
self.env['hr.attendance.overtime'].sudo().create(overtime_vals_list)
|
||||
overtime_to_unlink.sudo().unlink()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
res._update_overtime()
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
if vals.get('employee_id') and \
|
||||
vals['employee_id'] not in self.env.user.employee_ids.ids and \
|
||||
not self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
raise AccessError(_("Do not have access, user cannot edit the attendances that are not his own."))
|
||||
attendances_dates = self._get_attendances_dates()
|
||||
result = super(HrAttendance, self).write(vals)
|
||||
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)
|
||||
return result
|
||||
|
||||
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,33 @@
|
|||
# -*- 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'
|
||||
_order = 'date desc'
|
||||
|
||||
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))
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import pytz
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import models, fields, api, exceptions, _
|
||||
from odoo.tools import float_round
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = "hr.employee"
|
||||
|
||||
attendance_ids = fields.One2many(
|
||||
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
last_attendance_id = fields.Many2one(
|
||||
'hr.attendance', compute='_compute_last_attendance_id', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
last_check_in = fields.Datetime(
|
||||
related='last_attendance_id.check_in', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
last_check_out = fields.Datetime(
|
||||
related='last_attendance_id.check_out', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
attendance_state = fields.Selection(
|
||||
string="Attendance Status", compute='_compute_attendance_state',
|
||||
selection=[('checked_out', "Checked out"), ('checked_in', "Checked in")],
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
hours_last_month = fields.Float(
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
hours_today = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
hours_last_month_display = fields.Char(
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
overtime_ids = fields.One2many(
|
||||
'hr.attendance.overtime', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
total_overtime = fields.Float(
|
||||
compute='_compute_total_overtime', compute_sudo=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
|
||||
@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):
|
||||
"""
|
||||
Override to include checkin/checkout in the presence state
|
||||
Attendance has the second highest priority after login
|
||||
"""
|
||||
super()._compute_presence_state()
|
||||
employees = self.filtered(lambda e: e.hr_presence_state != 'present')
|
||||
employee_to_check_working = self.filtered(lambda e: e.attendance_state == 'checked_out'
|
||||
and e.hr_presence_state == 'to_define')
|
||||
working_now_list = employee_to_check_working._get_employee_working_now()
|
||||
for employee in employees:
|
||||
if employee.attendance_state == 'checked_out' and employee.hr_presence_state == 'to_define' and \
|
||||
employee.id not in working_now_list:
|
||||
employee.hr_presence_state = 'absent'
|
||||
elif employee.attendance_state == 'checked_in':
|
||||
employee.hr_presence_state = 'present'
|
||||
|
||||
def _compute_hours_last_month(self):
|
||||
now = fields.Datetime.now()
|
||||
now_utc = pytz.utc.localize(now)
|
||||
for employee in self:
|
||||
tz = pytz.timezone(employee.tz or 'UTC')
|
||||
now_tz = now_utc.astimezone(tz)
|
||||
start_tz = now_tz + relativedelta(months=-1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
end_tz = now_tz + relativedelta(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_naive = end_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
'&',
|
||||
('check_in', '<=', end_naive),
|
||||
('check_out', '>=', start_naive),
|
||||
])
|
||||
|
||||
hours = 0
|
||||
for attendance in attendances:
|
||||
check_in = max(attendance.check_in, start_naive)
|
||||
check_out = min(attendance.check_out, end_naive)
|
||||
hours += (check_out - check_in).total_seconds() / 3600.0
|
||||
|
||||
employee.hours_last_month = round(hours, 2)
|
||||
employee.hours_last_month_display = "%g" % employee.hours_last_month
|
||||
|
||||
def _compute_hours_today(self):
|
||||
now = fields.Datetime.now()
|
||||
now_utc = pytz.utc.localize(now)
|
||||
for employee in self:
|
||||
# start of day in the employee's timezone might be the previous day in utc
|
||||
tz = pytz.timezone(employee.tz)
|
||||
now_tz = now_utc.astimezone(tz)
|
||||
start_tz = now_tz + relativedelta(hour=0, minute=0) # day start in the employee's timezone
|
||||
start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '<=', now),
|
||||
'|', ('check_out', '>=', start_naive), ('check_out', '=', False),
|
||||
])
|
||||
|
||||
worked_hours = 0
|
||||
for attendance in attendances:
|
||||
delta = (attendance.check_out or now) - max(attendance.check_in, start_naive)
|
||||
worked_hours += delta.total_seconds() / 3600.0
|
||||
employee.hours_today = worked_hours
|
||||
|
||||
@api.depends('attendance_ids')
|
||||
def _compute_last_attendance_id(self):
|
||||
for employee in self:
|
||||
employee.last_attendance_id = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
], limit=1)
|
||||
|
||||
@api.depends('last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id')
|
||||
def _compute_attendance_state(self):
|
||||
for employee in self:
|
||||
att = employee.last_attendance_id.sudo()
|
||||
employee.attendance_state = att and not att.check_out and 'checked_in' or 'checked_out'
|
||||
|
||||
@api.model
|
||||
def attendance_scan(self, barcode):
|
||||
""" Receive a barcode scanned from the Kiosk Mode and change the attendances of corresponding employee.
|
||||
Returns either an action or a warning.
|
||||
"""
|
||||
employee = self.sudo().search([('barcode', '=', barcode)], limit=1)
|
||||
if employee:
|
||||
return employee._attendance_action('hr_attendance.hr_attendance_action_kiosk_mode')
|
||||
return {'warning': _("No employee corresponding to Badge ID '%(barcode)s.'") % {'barcode': barcode}}
|
||||
|
||||
def attendance_manual(self, next_action, entered_pin=None):
|
||||
self.ensure_one()
|
||||
attendance_user_and_no_pin = self.user_has_groups(
|
||||
'hr_attendance.group_hr_attendance_user,'
|
||||
'!hr_attendance.group_hr_attendance_use_pin')
|
||||
can_check_without_pin = attendance_user_and_no_pin or (self.user_id == self.env.user and entered_pin is None)
|
||||
if can_check_without_pin or entered_pin is not None and entered_pin == self.sudo().pin:
|
||||
return self._attendance_action(next_action)
|
||||
if not self.user_has_groups('hr_attendance.group_hr_attendance_user'):
|
||||
return {'warning': _('To activate Kiosk mode without pin code, you must have access right as an Officer or above in the Attendance app. Please contact your administrator.')}
|
||||
return {'warning': _('Wrong PIN')}
|
||||
|
||||
def _attendance_action(self, next_action):
|
||||
""" Changes the attendance of the employee.
|
||||
Returns an action to the check in/out message,
|
||||
next_action defines which menu the check in/out message should return to. ("My Attendances" or "Kiosk Mode")
|
||||
"""
|
||||
self.ensure_one()
|
||||
employee = self.sudo()
|
||||
action_message = self.env["ir.actions.actions"]._for_xml_id("hr_attendance.hr_attendance_action_greeting_message")
|
||||
action_message['previous_attendance_change_date'] = employee.last_attendance_id and (employee.last_attendance_id.check_out or employee.last_attendance_id.check_in) or False
|
||||
action_message['employee_name'] = employee.name
|
||||
action_message['barcode'] = employee.barcode
|
||||
action_message['next_action'] = next_action
|
||||
action_message['hours_today'] = employee.hours_today
|
||||
action_message['kiosk_delay'] = employee.company_id.attendance_kiosk_delay * 1000
|
||||
|
||||
if employee.user_id:
|
||||
modified_attendance = employee.with_user(employee.user_id).sudo()._attendance_action_change()
|
||||
else:
|
||||
modified_attendance = employee._attendance_action_change()
|
||||
action_message['attendance'] = modified_attendance.read()[0]
|
||||
action_message['show_total_overtime'] = employee.company_id.hr_attendance_overtime
|
||||
action_message['total_overtime'] = employee.total_overtime
|
||||
# Overtime have an unique constraint on the day, no need for limit=1
|
||||
action_message['overtime_today'] = self.env['hr.attendance.overtime'].sudo().search([
|
||||
('employee_id', '=', employee.id), ('date', '=', fields.Date.context_today(self)), ('adjustment', '=', False)]).duration or 0
|
||||
return {'action': action_message}
|
||||
|
||||
def _attendance_action_change(self):
|
||||
""" Check In/Check Out action
|
||||
Check In: create a new attendance record
|
||||
Check Out: modify check_out field of appropriate attendance record
|
||||
"""
|
||||
self.ensure_one()
|
||||
action_date = fields.Datetime.now()
|
||||
|
||||
if self.attendance_state != 'checked_in':
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
}
|
||||
return self.env['hr.attendance'].create(vals)
|
||||
attendance = self.env['hr.attendance'].search([('employee_id', '=', self.id), ('check_out', '=', False)], limit=1)
|
||||
if attendance:
|
||||
attendance.check_out = action_date
|
||||
else:
|
||||
raise exceptions.UserError(_('Cannot perform check out on %(empl_name)s, could not find corresponding check in. '
|
||||
'Your attendances have probably been modified manually by human resources.') % {'empl_name': self.sudo().name, })
|
||||
return attendance
|
||||
|
||||
@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
if 'pin' in groupby or 'pin' in self.env.context.get('group_by', '') or self.env.context.get('no_group_by'):
|
||||
raise exceptions.UserError(_('Such grouping is not allowed.'))
|
||||
return super(HrEmployee, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
||||
|
||||
def _compute_presence_icon(self):
|
||||
res = super()._compute_presence_icon()
|
||||
# All employee must chek in or check out. Everybody must have an icon
|
||||
self.filtered(lambda employee: not employee.show_hr_icon_display).show_hr_icon_display = True
|
||||
return res
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class HrEmployeePublic(models.Model):
|
||||
_inherit = 'hr.employee.public'
|
||||
|
||||
# These are required for manual attendance
|
||||
attendance_state = fields.Selection(related='employee_id.attendance_state', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
hours_today = fields.Float(related='employee_id.hours_today', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
last_attendance_id = fields.Many2one(related='employee_id.last_attendance_id', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
|
||||
def action_employee_kiosk_confirm(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'name': 'Confirm',
|
||||
'tag': 'hr_attendance_kiosk_confirm',
|
||||
'employee_id': self.id,
|
||||
'employee_name': self.name,
|
||||
'employee_state': self.attendance_state,
|
||||
'employee_hours_today': self.hours_today,
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrUiMenu(models.Model):
|
||||
_inherit = 'ir.ui.menu'
|
||||
|
||||
def _load_menus_blacklist(self):
|
||||
res = super()._load_menus_blacklist()
|
||||
att_menu = self.env.ref('hr_attendance.menu_hr_attendance_attendances_overview', raise_if_not_found=False)
|
||||
if att_menu and self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
res.append(att_menu.id)
|
||||
return res
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
# -*- 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)
|
||||
|
||||
attendance_kiosk_mode = fields.Selection([
|
||||
('barcode', 'Barcode / RFID'),
|
||||
('barcode_manual', 'Barcode / RFID and Manual Selection'),
|
||||
('manual', 'Manual Selection'),
|
||||
], string='Attendance Mode', default='barcode_manual')
|
||||
attendance_barcode_source = fields.Selection([
|
||||
('scanner', 'Scanner'),
|
||||
('front', 'Front Camera'),
|
||||
('back', 'Back Camera'),
|
||||
], string='Barcode Source', default='front')
|
||||
attendance_kiosk_delay = fields.Integer(default=10)
|
||||
|
||||
def write(self, vals):
|
||||
search_domain = False # Overtime to generate
|
||||
delete_domain = False # Overtime to delete
|
||||
|
||||
overtime_enabled_companies = self.filtered('hr_attendance_overtime')
|
||||
# Prevent any further logic if we are disabling the feature
|
||||
is_disabling_overtime = False
|
||||
# If we disable overtime
|
||||
if 'hr_attendance_overtime' in vals and not vals['hr_attendance_overtime'] and overtime_enabled_companies:
|
||||
delete_domain = [('company_id', 'in', overtime_enabled_companies.ids)]
|
||||
vals['overtime_start_date'] = False
|
||||
is_disabling_overtime = True
|
||||
|
||||
start_date = vals.get('hr_attendance_overtime') and vals.get('overtime_start_date')
|
||||
# Also recompute if the threshold have changed
|
||||
if not is_disabling_overtime and (
|
||||
start_date or 'overtime_company_threshold' in vals or 'overtime_employee_threshold' in vals):
|
||||
for company in self:
|
||||
# If we modify the thresholds only
|
||||
if start_date == company.overtime_start_date and \
|
||||
(vals.get('overtime_company_threshold') != company.overtime_company_threshold) or\
|
||||
(vals.get('overtime_employee_threshold') != company.overtime_employee_threshold):
|
||||
search_domain = OR([search_domain, [('employee_id.company_id', '=', company.id)]])
|
||||
# If we enabled the overtime with a start date
|
||||
elif not company.overtime_start_date and start_date:
|
||||
search_domain = OR([search_domain, [
|
||||
('employee_id.company_id', '=', company.id),
|
||||
('check_in', '>=', start_date)]])
|
||||
# If we move the start date into the past
|
||||
elif start_date and company.overtime_start_date > start_date:
|
||||
search_domain = OR([search_domain, [
|
||||
('employee_id.company_id', '=', company.id),
|
||||
('check_in', '>=', start_date),
|
||||
('check_in', '<=', company.overtime_start_date)]])
|
||||
# If we move the start date into the future
|
||||
elif start_date and company.overtime_start_date < start_date:
|
||||
delete_domain = OR([delete_domain, [
|
||||
('company_id', '=', company.id),
|
||||
('date', '<', start_date)]])
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
group_attendance_use_pin = fields.Boolean(
|
||||
string='Employee PIN',
|
||||
implied_group="hr_attendance.group_hr_attendance_use_pin")
|
||||
hr_attendance_overtime = fields.Boolean(
|
||||
string="Count Extra Hours", readonly=False)
|
||||
overtime_start_date = fields.Date(string="Extra Hours Starting Date", readonly=False)
|
||||
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)
|
||||
attendance_kiosk_mode = fields.Selection(related='company_id.attendance_kiosk_mode', readonly=False)
|
||||
attendance_barcode_source = fields.Selection(related='company_id.attendance_barcode_source', readonly=False)
|
||||
attendance_kiosk_delay = fields.Integer(related='company_id.attendance_kiosk_delay', readonly=False)
|
||||
|
||||
@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().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})
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
_inherit = ['res.users']
|
||||
|
||||
hours_last_month = fields.Float(related='employee_id.hours_last_month')
|
||||
hours_last_month_display = fields.Char(related='employee_id.hours_last_month_display')
|
||||
attendance_state = fields.Selection(related='employee_id.attendance_state')
|
||||
last_check_in = fields.Datetime(related='employee_id.last_attendance_id.check_in')
|
||||
last_check_out = fields.Datetime(related='employee_id.last_attendance_id.check_out')
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime')
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + [
|
||||
'hours_last_month',
|
||||
'hours_last_month_display',
|
||||
'attendance_state',
|
||||
'last_check_in',
|
||||
'last_check_out',
|
||||
'total_overtime'
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue