diff --git a/addons_bp/bp_emp_attendance/__init__.py b/addons_bp/bp_emp_attendance/__init__.py new file mode 100644 index 0000000..57568e3 --- /dev/null +++ b/addons_bp/bp_emp_attendance/__init__.py @@ -0,0 +1,6 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models +from . import wizard +from . import report diff --git a/addons_bp/bp_emp_attendance/__manifest__.py b/addons_bp/bp_emp_attendance/__manifest__.py new file mode 100644 index 0000000..6d1037a --- /dev/null +++ b/addons_bp/bp_emp_attendance/__manifest__.py @@ -0,0 +1,35 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Employee Attendance & Extra Hours", + "sequence": 20, + "version": "1.0.0", + "category": "Human Resources/Attendances", + "author": "bloopark systems GmbH & Co. KG", + "website": "http://www.bloopark.de", + "license": "LGPL-3", + "depends": [ + "hr_attendance", + "hr_holidays_attendance", + ], + "installable": True, + "auto_install": False, + "application": False, + "data": [ + # security files + "security/ir.model.access.csv", + "security/attendance_security.xml", + # Wizard files + "wizard/hr_attendance_extra_hours_wizard.xml", + # Views files + "views/hr_attendance_view.xml", + "views/hr_employee.xml", + "views/hr_leave_views.xml", + "views/hr_attendance_overtime_views.xml", + # Data files + "data/attendance_data.xml", + # Report files + "report/hr_custom_report_attendance_views.xml", + ], + "demo": [], +} diff --git a/addons_bp/bp_emp_attendance/data/attendance_data.xml b/addons_bp/bp_emp_attendance/data/attendance_data.xml new file mode 100644 index 0000000..7aa175c --- /dev/null +++ b/addons_bp/bp_emp_attendance/data/attendance_data.xml @@ -0,0 +1,203 @@ + + + + + Attendance: notify employee/manager missing day + + code + model.cron_missing_attendance() + + 1 + days + -1 + + + + + Manager attendance: Daily attendance check + + Reminder to log employee manager timesheet + {{ (object.user_id.company_id.partner_id.email_formatted or user.email_formatted) }} + {{ object.parent_id.work_email }} + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + +
+ Manager timesheet reminder + + +
+
+
+
+ + + + +
+
+ Hi + + , +
+
+ I'd like to inform you that" + + + + ", +
+ who's working in( + + + + ) department, didn't log her/his attendance for today. +
+
+ Thanks. +
+
+
+
+ +
+ +
+
+
+ +
+ + + Employee attendance: Daily attendance check + + Reminder to log employee timesheet + {{ (object.user_id.company_id.partner_id.email_formatted or user.email_formatted) }} + {{ object.work_email }} + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + +
+ Employee timesheet reminder + + +
+
+
+
+ + + + +
+
+ Hi + + , +
+
+ I'd like to inform you that, you didn't log your attendance for today. +
+ Please contact + ! +
+
+ Thanks. +
+
+
+
+ +
+ +
+
+
+ +
+ + + Update Previous Attendance + + + code + action = records._update_previous_attendance_action() + + +
+
diff --git a/addons_bp/bp_emp_attendance/migrations/16.0.0.0.2/post-update-attendance-overtime.py b/addons_bp/bp_emp_attendance/migrations/16.0.0.0.2/post-update-attendance-overtime.py new file mode 100644 index 0000000..0186bf1 --- /dev/null +++ b/addons_bp/bp_emp_attendance/migrations/16.0.0.0.2/post-update-attendance-overtime.py @@ -0,0 +1,10 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import SUPERUSER_ID, api + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + overtimes = env["hr.attendance.overtime"].search([]) + overtimes._compute_overtime_data() + overtimes.action_update_day_overtime() diff --git a/addons_bp/bp_emp_attendance/models/__init__.py b/addons_bp/bp_emp_attendance/models/__init__.py new file mode 100644 index 0000000..257bfe9 --- /dev/null +++ b/addons_bp/bp_emp_attendance/models/__init__.py @@ -0,0 +1,7 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import employee_attendance +from . import hr_employee +from . import attendance_overtime +from . import hr_leave diff --git a/addons_bp/bp_emp_attendance/models/attendance_overtime.py b/addons_bp/bp_emp_attendance/models/attendance_overtime.py new file mode 100644 index 0000000..5135f72 --- /dev/null +++ b/addons_bp/bp_emp_attendance/models/attendance_overtime.py @@ -0,0 +1,124 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class HrAttendanceOvertime(models.Model): + _inherit = "hr.attendance.overtime" + + request_date = fields.Date( + compute="_compute_overtime_data", + store=True, + string='Day (Request)', + ) + leave_ids = fields.One2many( + "hr.leave", + "overtime_id", + readonly=True, + string="Leaves", + ) + worked_hours = fields.Float( + compute="_compute_overtime_data", + store=True, + ) + date = fields.Date(string='Day (Approved)') + + @api.depends("leave_ids", "adjustment", "date", "employee_id", "duration") + def _compute_overtime_data(self): + hr_att = self.env["hr.attendance"].sudo() + for record in self: + worked_hours = 0.0 + request_date = record.date or False + if record.adjustment and record.leave_ids: + request_date = record.leave_ids[0].request_date_from + date_end = request_date + relativedelta(days=1) + + # Day attendance hours + if not record.adjustment: + att_group = hr_att.read_group( + [ + ("employee_id", "=", record.employee_id.id), + ("check_in", ">=", request_date), + ("check_out", "<", date_end), + ], + groupby=["employee_id"], + fields=["employee_id", "worked_hours"], + lazy=False, + ) + day_attendance = sum(x.get("worked_hours") for x in att_group) + worked_hours = day_attendance + + record.request_date = request_date + record.worked_hours = worked_hours + + @api.model_create_multi + def create(self, vals): + res = super().create(vals) + for record in res: + record.action_update_day_overtime() + return res + + def write(self, vals): + res = super().write(vals) + my_list_set = { + "employee_id", + "date", + } + my_dict_set = set(vals) + if my_list_set.intersection(my_dict_set): + self.action_update_day_overtime() + return res + + def action_update_day_overtime(self): + hr_leaves = self.env["hr.leave"].sudo() + for record in self.sudo().filtered(lambda o: not o.adjustment): + date_from = record.date + employee = record.employee_id + resource_calendar = record.employee_id.resource_calendar_id + if not resource_calendar: + raise UserError( + "Working Hours are not set for %s " + % employee.name + ) + + dt_from = datetime.combine(date_from, datetime.min.time()) + dt_to = datetime.combine(date_from, datetime.max.time()) + work_data = resource_calendar.get_work_duration_data( + from_datetime=dt_from, + to_datetime=dt_to, + compute_leaves=True, + ) + required_hours = work_data.get("hours") or 0.00 + day_attendance = ( + self.env["hr.attendance"] + .sudo() + .search( + [ + ("employee_id", "=", employee.id), + ("check_in", ">=", dt_from), + ("check_in", "<", dt_to), + ], + order="check_in asc", + ) + ) + logged_hours = sum(day_attendance.mapped("worked_hours")) + + # Check employee leaves ['Extra hours' or 'On Vacations'] + emp_leaves_ids = employee.check_employee_vacation( + date_from=dt_from, + date_to=dt_to, + ) + emp_leaves = hr_leaves.browse(emp_leaves_ids) + overtime_leaves = emp_leaves.filtered( + lambda l: l.holiday_status_id.overtime_deductible + ).mapped("overtime_id") + leave_hours_dur = sum(overtime_leaves.mapped("duration")) or 0.0 + if emp_leaves and not overtime_leaves: + leave_hours_dur = -1 * required_hours + + record.duration = logged_hours - required_hours - leave_hours_dur diff --git a/addons_bp/bp_emp_attendance/models/employee_attendance.py b/addons_bp/bp_emp_attendance/models/employee_attendance.py new file mode 100644 index 0000000..3742d7d --- /dev/null +++ b/addons_bp/bp_emp_attendance/models/employee_attendance.py @@ -0,0 +1,335 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import date, datetime, timedelta + +import pytz +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models +from odoo.tools import Markup + + + +class HrAttendance(models.Model): + _inherit = "hr.attendance" + + net_working_time = fields.Float(copy=False) + employee_break_time = fields.Float(copy=False, string='Deducted Time') + employee_extra_hours = fields.Float(copy=False) + worked_hours = fields.Float( + compute="_compute_de_worked_hours", + store=True, + ) + + def _update_overtime_hours(self, over_hours): + self.write({'worked_hours': over_hours}) + + @api.model_create_multi + def create(self, vals): + res = super().create(vals) + for record in res: + record.action_recompute_worked_hours() + record.action_update_overtime() + return res + + def write(self, vals): + for attendance in self: + if (attendance.check_in and vals.get("check_in")) or ( + attendance.check_out and vals.get("check_out") + ): + attendance.action_log_employee_message( + old_values=attendance.read()[0], + new_values=vals, + ) + + res = super().write(vals) + my_list_set = { + "employee_id", + "check_in", + "check_out", + "employee_extra_hours", + } + my_dict_set = set(vals) + if my_list_set.intersection(my_dict_set): + self.action_recompute_worked_hours() + self.action_update_overtime() + return res + + @api.depends( + "net_working_time", + "employee_break_time", + "employee_extra_hours", + ) + def _compute_de_worked_hours(self): + """This method implemented to replace odoo standard calculation. + + From (check out - check_in) + To → employee_extra_hours + (net_working_time - employee_break_time) + + and also to prevent override/edit odoo from another modules, + that's why we added new method name _compute_de_worked_hours + """ + for attendance in self: + net = attendance.net_working_time + break_time = attendance.employee_break_time + employee_extra_hours = attendance.employee_extra_hours + attendance.worked_hours = employee_extra_hours + (net - break_time) + + def action_recompute_worked_hours(self): + """Test break/net work time calculation is correctly computed If employee has + only 1 record check in/out in same day: case 1: working hours less than 6:00 h. + + → break_time should be (00:00) + case 2: working hours between than 6:00h ~ 6:30h + → break_time = (check_out - check_in) - 6 + case 3: working hours more than 6:30 h + → break_time = 00:30 hh:mm + + Else employee logged many record per day: + case 1: total day working hours less than 6:00 h + → break_time should be (00:00) + case 2: total day working hours between than 6:00h ~ 6:30h + → break_time = (sum_working_time - 6 - sum_break_time_before) + case 3: total day working hours more than 6:30 h + → break_time = 0.75 - sum_break_time_before + """ + for attendance in self: + check_in = attendance.check_in + check_out = attendance.check_out + employee_break_time = 0.0 + net_working_time = 0.0 + if (check_out and check_in) and (check_out > check_in): + tz = pytz.timezone(attendance.employee_id.tz or "UTC") + start_naive = check_in.astimezone(tz).replace( + hour=0, + minute=0, + second=0, + ) + end_naive = check_in.astimezone(tz).replace( + hour=23, + minute=59, + second=59, + ) + + # If employee logged his time in normal day + last_attendance = self.sudo().search( + [ + ("employee_id", "=", attendance.employee_id.id), + ("check_in", ">=", start_naive), + ("check_out", "<=", end_naive), + ("check_out", "<=", attendance.check_in), + ("id", "not in", attendance.ids), + ], + order="check_out desc", + limit=1, + ) + attendance_before = self.sudo().search( + [ + ("employee_id", "=", attendance.employee_id.id), + ("check_in", ">=", start_naive), + ("check_out", "<=", end_naive), + ("check_out", "<=", attendance.check_in), + ("id", "not in", attendance.ids), + ], + order="check_out desc", + ) + attendance_after = self.sudo().search( + [ + ("employee_id", "=", attendance.employee_id.id), + ("check_in", ">=", attendance.check_in), + ("check_out", "<=", end_naive), + ("id", "not in", attendance.ids), + ], + order="check_out desc", + limit=1, + ) + if attendance_after: + attendance_after.action_recompute_worked_hours() + + if not last_attendance: + net_working_time = self.get_diff_min( + check_in=check_in, + check_out=check_out, + ) + + if net_working_time > 9: + employee_break_time = 0.75 + + elif 6.5 < net_working_time <= 9: + employee_break_time = 0.50 + + elif 6 < net_working_time <= 6.5: + employee_break_time = net_working_time - 6 + + elif len(attendance_before) >= 1: + net_working_time = self.get_diff_min( + check_in=check_in, + check_out=check_out, + ) + sum_working_time = ( + sum(attendance_before.mapped("net_working_time")) + + net_working_time + ) + current = attendance + total_break_time = 0 + for record in attendance_before: + total_break_time += self.get_diff_min( + record.check_out, current.check_in + ) + current = record + + for record in attendance_before: + record.employee_break_time = 0 + + if sum_working_time > 9 and total_break_time < 0.75: + employee_break_time = 0.75 - total_break_time + elif 6.5 < sum_working_time <= 9.0 and total_break_time <= 0.5: + employee_break_time = 0.5 - total_break_time + + elif (6 <= sum_working_time <= 6.5) and ( + total_break_time < (sum_working_time - 6) + ): + employee_break_time = sum_working_time - 6 - total_break_time + elif sum_working_time > 9 and total_break_time > 0.75: + + net_working_time = net_working_time + employee_break_time = 0 + + attendance.net_working_time = net_working_time + attendance.employee_break_time = employee_break_time + + @api.model + def get_diff_min(self, check_in, check_out): + """This method implemented to calculate diff between and dates in minutes. + + :param check_in: check in datetime + :param check_out: check out datetime + :return: diff in minutes (check_out - check_in) + :rtype: float + """ + diff = check_out - check_in + diff_seconds = diff.total_seconds() + value = diff_seconds / 3600.0 + return value + + def action_update_overtime(self): + overtime = self.env["hr.attendance.overtime"].sudo() + for rec in self: + date_start = rec.check_in.date() + date_end = date_start + relativedelta(days=1) + day_overtime = overtime.search( + [ + ("employee_id", "=", rec.employee_id.id), + ("date", ">=", date_start), + ("date", "<", date_end), + ("adjustment", "=", False), + ] + ) + day_overtime.action_update_day_overtime() + if not day_overtime: + self.env["hr.attendance.overtime"].sudo().with_context( + { + 'worked_hours': rec.worked_hours, + } + ).create( + { + "employee_id": rec.employee_id.id, + "adjustment": False, + "date": date_start, + "duration": 0, + } + ) + + def action_log_employee_message(self, old_values, new_values): + employee_id = ( + new_values.get("employee_id") or old_values.get("employee_id")[0] + if old_values.get("employee_id") + else False + ) + + check_in = old_values.get("check_in") or new_values.get("check_in") + new_check_in = new_values.get("check_in") or False + + check_out = old_values.get("check_out") or new_values.get("check_out") + new_check_out = new_values.get("check_out") or False + + if employee_id and (new_check_in or new_check_out): + check_in = self.get_datetime_context_timestamp(check_in) + new_check_in = self.get_datetime_context_timestamp(new_check_in) + check_out = self.get_datetime_context_timestamp(check_out) + new_check_out = self.get_datetime_context_timestamp(new_check_out) + + employee = self.env["hr.employee"].sudo().browse(employee_id) + body = Markup (f"{self.env.user.name} changed Attendance Data:
") + if new_check_in != check_in and new_check_in: + body += Markup(f"
  • Check in : {check_in} → {new_check_in}
  • ") + if new_check_out != check_out and new_check_out: + body += Markup( + f"
  • Check out : {check_out} → {new_check_out}
  • " + ) + employee.message_post(body=body) + + def get_datetime_context_timestamp(self, data): + """ + :param data: datetime or sting datetime + :return: datetime with time stamp + """ + if not data: + return "" + datetime_data = fields.Datetime.from_string(str(data)) + return fields.Datetime.to_string( + fields.Datetime.context_timestamp(self, datetime_data) + ) + + @api.model + def action_create_missing_attendance(self, year=False): + if not year: + year = int(date.today().year) + date_from = date(year=int(year), month=1, day=1) + date_to = date.today() + range_date = range(int((date_to - date_from).days)) + dates_list = [date_from + timedelta(n) for n in range_date] + attendance_list = [] + + for ydate in dates_list: + employees = ( + self.env["hr.employee"] + .sudo() + .search( + [ + ("check_daily_attendance", "=", True), + ("first_contract_date", "<=", ydate), + ] + ) + ) + check_in = datetime.combine(ydate, datetime.min.time()) + check_out = datetime.combine(ydate, datetime.max.time()) + for emp in employees: + work_data = emp.resource_calendar_id.get_work_duration_data( + from_datetime=check_in, + to_datetime=check_out, + compute_leaves=True, + ) + leaves = emp.check_employee_vacation( + date_from=check_in, + date_to=check_out, + ) + + if work_data.get("hours") and not leaves: + attendance_day = self.sudo().search( + [ + ("employee_id", "=", emp.id), + ("check_in", ">=", check_in), + ("check_out", "<=", check_out), + ], + order="check_out desc", + ) + if not attendance_day: + attendance_list.append( + { + "employee_id": emp.id, + "check_in": check_in.replace(hour=4), + "check_out": check_in.replace(hour=4), + } + ) + self.sudo().create(attendance_list) diff --git a/addons_bp/bp_emp_attendance/models/hr_employee.py b/addons_bp/bp_emp_attendance/models/hr_employee.py new file mode 100644 index 0000000..f35af74 --- /dev/null +++ b/addons_bp/bp_emp_attendance/models/hr_employee.py @@ -0,0 +1,189 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from collections import namedtuple +from datetime import datetime, timedelta + +from odoo import _, api, fields, models + + +class HrEmployeePublic(models.Model): + _inherit = "hr.employee.public" + + is_employee_officer = fields.Boolean( + compute="_compute_is_employee_officer", + ) + check_daily_attendance = fields.Boolean( + related='employee_id.check_daily_attendance' + ) + send_missing_attendance_mail = fields.Boolean( + related='employee_id.send_missing_attendance_mail' + ) + + def hr_attendance_report_action(self): + return self.employee_id.hr_attendance_report_action() + + def _compute_is_employee_officer(self): + login_user = self.env.user + for rec in self.sudo(): + is_employee_officer = self.env.user.has_group( + "hr_attendance.group_hr_attendance_user" + ) + employee_user = rec.employee_id.user_id + + if not is_employee_officer and (login_user.id == employee_user.id): + is_employee_officer = True + + rec.is_employee_officer = is_employee_officer + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + check_daily_attendance = fields.Boolean() + send_missing_attendance_mail = fields.Boolean( + string="Send Daily Mail For Missing Attendance", + default=False, + ) + + def hr_attendance_report_action(self): + self.ensure_one() + return self.env["ir.actions.actions"]._for_xml_id( + "bp_emp_attendance.hr_custom_own_report_attendance_action" + ) + + def action_open_extra_hours_wizard(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Add/Deduct Employee Working Hours"), + "res_model": "hr.attendance.extra.hours", + "views": [(False, "form")], + "view_type": "form", + "view_mode": "form", + "target": "new", + "context": { + "default_employee_id": self.id, + }, + } + + @api.model + def cron_missing_attendance(self): + date_now = datetime.now() + dt_from = datetime.combine(date_now, datetime.min.time()) + dt_to = datetime.combine(date_now, datetime.max.time()) + attendance = self.env["hr.attendance"].sudo() + employees = self.sudo().search( + [ + ("parent_id", "!=", False), + ("check_daily_attendance", "=", True), + ] + ) + employee_attendance = attendance.search( + [ + ("check_in", ">=", dt_from), + ("check_out", "<=", dt_to), + ], + order="check_out desc", + ).mapped("employee_id") + employees_to_log = list(set(employees) ^ set(employee_attendance)) + attendance_list = [] + check_dt = date_now.replace( + hour=6, + minute=0, + second=0, + microsecond=0, + ) + manager_template = self.env.ref( + "bp_emp_attendance.email_template_manager_daily_attendance", + raise_if_not_found=False, + ) + employee_template = self.env.ref( + "bp_emp_attendance.email_template_employee_daily_attendance", + raise_if_not_found=False, + ) + + for employee in employees_to_log: + work_data = employee.resource_calendar_id.get_work_duration_data( + from_datetime=dt_from, + to_datetime=dt_to, + compute_leaves=True, + ) + leaves = employee.check_employee_vacation( + date_from=dt_from, + date_to=dt_to, + ) + + if work_data.get("hours") and not leaves: + attendance_list.append( + { + "employee_id": employee.id, + "check_in": check_dt, + "check_out": check_dt, + } + ) + if employee.send_missing_attendance_mail: + manager_template.sudo().send_mail( + employee.id, + force_send=True, + ) + employee_template.sudo().send_mail( + employee.id, + force_send=True, + ) + attendance.create(attendance_list) + + def check_employee_vacation(self, date_from, date_to): + self.ensure_one() + hr_leaves = self.env["hr.leave"].sudo() + holidays = hr_leaves.search( + [ + ("employee_id", "=", self.id), + ("state", "=", "validate"), + ] + ) + + overlap_list = [] + Range = namedtuple("Range", ["start", "end"]) + r2 = Range(start=date_from, end=date_to) + for line in holidays: + r1 = Range(start=line.date_from, end=line.date_to) + latest_start = max(r1.start, r2.start) + earliest_end = min(r1.end, r2.end) + delta = (earliest_end - latest_start).days + 1 + overlap = max(0, delta) + if overlap and (line not in overlap_list): + overlap_list.append(line.id) + return overlap_list + + def _update_previous_attendance_action(self): + """Function for server action to update previous attendances for the + employee.""" + dates = [] + date_now = datetime.now() + # Starting date to update attendance record + date_start = datetime(2023, 8, 15) + attendance = self.env["hr.attendance"].sudo() + + while date_start: + if date_start != datetime.combine(date_now, datetime.min.time()): + dates.append(date_start) + date_start = date_start + timedelta(days=1) + else: + date_start = False + + for each_date in dates: + dt_from = datetime.combine(each_date, datetime.min.time()) + dt_to = datetime.combine(each_date, datetime.max.time()) + + for employee in self: + employee_attendance = attendance.search( + [ + ("check_in", ">=", dt_from), + ("check_out", "<=", dt_to), + ("employee_id", "=", employee.id), + ], + order="check_out desc", + limit=1, + ) + employee_attendance.action_recompute_worked_hours() diff --git a/addons_bp/bp_emp_attendance/models/hr_leave.py b/addons_bp/bp_emp_attendance/models/hr_leave.py new file mode 100644 index 0000000..2335d4a --- /dev/null +++ b/addons_bp/bp_emp_attendance/models/hr_leave.py @@ -0,0 +1,31 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import api, fields, models + + +class HrLeave(models.Model): + _inherit = "hr.leave" + + employee_overtime = fields.Float(related="employee_id.total_overtime") + + def action_refuse(self): + if self.env.user.has_group('bp_emp_attendance.group_cancel_extra_hours'): + self.mapped("overtime_id").unlink() + super().action_refuse() + + @api.depends('holiday_status_id') + def _compute_overtime_deductible(self): + has_group = self.env.user.has_group( + 'bp_emp_attendance.group_cancel_extra_hours' + ) + return ( + self.write( + { + "overtime_deductible": False, + } + ) + if has_group + else super()._compute_overtime_deductible() + ) diff --git a/addons_bp/bp_emp_attendance/report/__init__.py b/addons_bp/bp_emp_attendance/report/__init__.py new file mode 100644 index 0000000..abac9d6 --- /dev/null +++ b/addons_bp/bp_emp_attendance/report/__init__.py @@ -0,0 +1,4 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import hr_custom_report_attendance diff --git a/addons_bp/bp_emp_attendance/report/hr_custom_report_attendance.py b/addons_bp/bp_emp_attendance/report/hr_custom_report_attendance.py new file mode 100644 index 0000000..bb21ccf --- /dev/null +++ b/addons_bp/bp_emp_attendance/report/hr_custom_report_attendance.py @@ -0,0 +1,70 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, tools + + +class HRAttendanceReport(models.Model): + _name = "custom.hr.attendance.report" + _description = "Attendance Report Custom" + _auto = False + + employee_id = fields.Many2one("hr.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) + department_id = fields.Many2one("hr.department", readonly=True) + + @api.model + def _select(self): + return """ + SELECT + ovr.id, + ovr.employee_id, + ovr.check_in, + worked_hours, + department_id, + ovr.overtime_hours + """ + + @api.model + def _from(self): + return """ + FROM( + SELECT + id, + row_number() over (partition by employee_id, CAST(date AS DATE)) as ot_check, + employee_id, + worked_hours, + CAST(request_date as DATE) as check_in, + duration as overtime_hours + FROM + hr_attendance_overtime + + ) as ovr + """ + + def _join(self): + return """ + LEFT JOIN hr_employee ON hr_employee.id = ovr.employee_id + """ + + def init(self): + tools.drop_view_if_exists(self.env.cr, self._table) + self.env.cr.execute( + """ + CREATE OR REPLACE VIEW %s AS ( + %s + %s + %s + ) + """ + % ( + self._table, + self._select(), + self._from(), + self._join(), + ) + ) diff --git a/addons_bp/bp_emp_attendance/report/hr_custom_report_attendance_views.xml b/addons_bp/bp_emp_attendance/report/hr_custom_report_attendance_views.xml new file mode 100644 index 0000000..b24c181 --- /dev/null +++ b/addons_bp/bp_emp_attendance/report/hr_custom_report_attendance_views.xml @@ -0,0 +1,110 @@ + + + + + + custom.hr.attendance.report.tree + custom.hr.attendance.report + + + + + + + + + + + + + custom.hr.attendance.report.form + custom.hr.attendance.report + +
    + + + + + + + + + + +
    +
    +
    + + + custom.hr.attendance.report.pivot + custom.hr.attendance.report + + + + + + + + + + + + + + custom.hr.attendance.report.search + custom.hr.attendance.report + + + + + + + + + + + + + + + + + + Employees Report + ir.actions.act_window + custom.hr.attendance.report + pivot,tree + + + + Employee Attendance Report + ir.actions.act_window + custom.hr.attendance.report + pivot,tree + [('employee_id.user_id','=',uid)] + + + + + + + + Attendance Analysis + 1 + + + +
    + +
    diff --git a/addons_bp/bp_emp_attendance/security/attendance_security.xml b/addons_bp/bp_emp_attendance/security/attendance_security.xml new file mode 100644 index 0000000..6978975 --- /dev/null +++ b/addons_bp/bp_emp_attendance/security/attendance_security.xml @@ -0,0 +1,16 @@ + + + + + Can edit TimeSheet + + + + + + Can refuse approved extra hours requests + + + + + diff --git a/addons_bp/bp_emp_attendance/security/ir.model.access.csv b/addons_bp/bp_emp_attendance/security/ir.model.access.csv new file mode 100644 index 0000000..2a169d7 --- /dev/null +++ b/addons_bp/bp_emp_attendance/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +bp_emp_attendance.access_hr_attendance_extra_hours,access_hr_attendance_extra_hours,bp_emp_attendance.model_hr_attendance_extra_hours,base.group_user,1,1,1,1 +bp_emp_attendance.access_custom_hr_attendance_report,access_custom_hr_attendance_report,bp_emp_attendance.model_custom_hr_attendance_report,base.group_user,1,1,1,1 diff --git a/addons_bp/bp_emp_attendance/static/description/icon.png b/addons_bp/bp_emp_attendance/static/description/icon.png new file mode 100644 index 0000000..2a7dfd1 Binary files /dev/null and b/addons_bp/bp_emp_attendance/static/description/icon.png differ diff --git a/addons_bp/bp_emp_attendance/tests/__init__.py b/addons_bp/bp_emp_attendance/tests/__init__.py new file mode 100644 index 0000000..7a16486 --- /dev/null +++ b/addons_bp/bp_emp_attendance/tests/__init__.py @@ -0,0 +1,4 @@ +# © 2023 bloopark systems () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_hr_attendance_break_time diff --git a/addons_bp/bp_emp_attendance/tests/test_hr_attendance_break_time.py b/addons_bp/bp_emp_attendance/tests/test_hr_attendance_break_time.py new file mode 100644 index 0000000..a889959 --- /dev/null +++ b/addons_bp/bp_emp_attendance/tests/test_hr_attendance_break_time.py @@ -0,0 +1,162 @@ +from datetime import datetime + +import pytz +from odoo.tests import new_test_user +from odoo.tests.common import TransactionCase + + +def tz_datetime(year, month, day, hour, minute): + tz = pytz.timezone("Europe/Brussels") + return ( + tz.localize(datetime(year, month, day, hour, minute)) + .astimezone(pytz.utc) + .replace(tzinfo=None) + ) + + +class TestHrAttendance(TransactionCase): + """Test for break time calculation.""" + + def setUp(self): + super().setUp() + self.employee_user = new_test_user( + self.env, + login="bp", + groups="base.group_user", + ) + self.employee_login = self.env["hr.employee"].create( + { + "name": "Martin Holz", + "pin": "4477", + "user_id": self.employee_user.id, + } + ) + + def get_attendance_data(self, attendance_id): + attendance = self.env["hr.attendance"].browse(int(attendance_id)) + return { + "net_working_time": round(attendance.net_working_time, 2), + "employee_break_time": round(attendance.employee_break_time, 2), + "worked_hours": round(attendance.worked_hours, 2), + } + + def test_employee_checkout(self): + # Make sure the attendance of the employee already checked out + assert self.employee_login.attendance_state == "checked_out" + + def test_attendance_case_1(self): + """Test break/net work time calculation is correctly computed if employee has + only one attendance log record.""" + + # Case 1: Employee total working hours less than 6 + attendance = self.env["hr.attendance"].create( + { + "employee_id": self.employee_login.id, + "check_in": tz_datetime(2022, 4, 7, 8, 0), + "check_out": tz_datetime(2022, 4, 7, 12, 0), + } + ) + attendance_data = self.get_attendance_data(attendance_id=attendance.id) + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + assert net == 4.0 + assert break_time == 0.0 + assert round(attendance.worked_hours, 2) == worked_hours + + # Case 2: Employee total working hours between 6:15 less than 6:30 + attendance.check_out = tz_datetime(2022, 4, 7, 14, 15) + attendance_data = self.get_attendance_data(attendance_id=attendance.id) + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + + assert net == 6.25 + assert break_time == 0.25 + assert round(attendance.worked_hours, 2) == worked_hours + + # Case 4: Employee total working hours greater than 06:30 + attendance.check_out = tz_datetime(2022, 4, 7, 16, 15) + attendance_data = self.get_attendance_data(attendance_id=attendance.id) + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + + assert net == 8.25 + assert break_time == 0.5 + assert round(attendance.worked_hours, 2) == worked_hours + + def test_attendance_case_2(self): + """Test break/net work time calculation is correctly computed if employee has + multi check in/out in same day.""" + # Case 1 + attendance_1 = self.env["hr.attendance"].create( + { + "employee_id": self.employee_login.id, + "check_in": tz_datetime(2022, 4, 7, 8, 0), + "check_out": tz_datetime(2022, 4, 7, 9, 0), + } + ) + attendance_data = self.get_attendance_data( + attendance_id=attendance_1.id, + ) + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + + assert net == 1.00 + assert break_time == 0.0 + assert round(attendance_1.worked_hours, 2) == worked_hours + + # Case 2 + attendance_2 = self.env["hr.attendance"].create( + { + "employee_id": self.employee_login.id, + "check_in": tz_datetime(2022, 4, 7, 9, 00), + "check_out": tz_datetime(2022, 4, 7, 17, 00), + } + ) + attendance_data = self.get_attendance_data( + attendance_id=attendance_2.id, + ) + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + assert net == 8.00 + assert break_time == 0.5 + assert round(attendance_2.worked_hours, 2) == worked_hours + + # Case 3: Employee total working hours above 9hrs + attendance_3 = self.env["hr.attendance"].create( + { + "employee_id": self.employee_login.id, + "check_in": tz_datetime(2022, 4, 8, 8, 00), + "check_out": tz_datetime(2022, 4, 8, 18, 00), + } + ) + attendance_data = self.get_attendance_data( + attendance_id=attendance_3.id, + ) + attendance_3.action_recompute_worked_hours() + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + assert net == 10 + assert break_time == 0.75 # 45 min + assert round(attendance_3.worked_hours, 2) == worked_hours + + # Case 4: Employee total working hours exact 9hr + attendance_3.write( + { + "check_out": tz_datetime(2022, 4, 8, 17, 00), + } + ) + attendance_data = self.get_attendance_data( + attendance_id=attendance_3.id, + ) + net = attendance_data.get("net_working_time") + break_time = attendance_data.get("employee_break_time") + worked_hours = net - break_time + assert net == 9.0 + assert break_time == 0.5 # 30 min + assert round(attendance_3.worked_hours, 2) == worked_hours diff --git a/addons_bp/bp_emp_attendance/views/hr_attendance_overtime_views.xml b/addons_bp/bp_emp_attendance/views/hr_attendance_overtime_views.xml new file mode 100644 index 0000000..3720d1f --- /dev/null +++ b/addons_bp/bp_emp_attendance/views/hr_attendance_overtime_views.xml @@ -0,0 +1,22 @@ + + + + + hr.attendance.overtime.view.tree + hr.attendance.overtime + + + + + + + hide + + + Sum Duration + + + + + + diff --git a/addons_bp/bp_emp_attendance/views/hr_attendance_view.xml b/addons_bp/bp_emp_attendance/views/hr_attendance_view.xml new file mode 100644 index 0000000..4f275ba --- /dev/null +++ b/addons_bp/bp_emp_attendance/views/hr_attendance_view.xml @@ -0,0 +1,30 @@ + + + + + hr.attendance.tree + hr.attendance + + + + + + + + + + + hr.employee.view.form.hr + hr.employee + + + + + + + + + + + + + + + + + + + + + + + +