diff --git a/hr_attendance_sheet/__manifest__.py b/hr_attendance_sheet/__manifest__.py index 441b112f..763668c2 100644 --- a/hr_attendance_sheet/__manifest__.py +++ b/hr_attendance_sheet/__manifest__.py @@ -16,10 +16,12 @@ "data": [ "security/ir.model.access.csv", "security/security_groups.xml", + "security/hr_attendance_sheet_config_acl.xml", "data/cron.xml", "data/mail_data.xml", "report/hr_attendance_sheet_report.xml", "views/hr_attendance_sheet.xml", + "views/hr_attendance_sheet_config.xml", "views/hr_attendance_view.xml", "views/hr_department.xml", "views/hr_employee.xml", diff --git a/hr_attendance_sheet/models/__init__.py b/hr_attendance_sheet/models/__init__.py index bafd8e9c..b95387a9 100644 --- a/hr_attendance_sheet/models/__init__.py +++ b/hr_attendance_sheet/models/__init__.py @@ -1,8 +1,10 @@ # Copyright 2020 Pavlov Media +# Copyright 2023 ACSONE SA/NV # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html from . import hr_attendance from . import hr_attendance_sheet +from . import hr_attendance_sheet_config from . import hr_department from . import hr_employee from . import res_company diff --git a/hr_attendance_sheet/models/hr_attendance.py b/hr_attendance_sheet/models/hr_attendance.py index fb897e1c..da9f68ef 100644 --- a/hr_attendance_sheet/models/hr_attendance.py +++ b/hr_attendance_sheet/models/hr_attendance.py @@ -26,12 +26,13 @@ class HrAttendance(models.Model): auto_lunch = fields.Boolean( string="Auto Lunch Applied", help="If Auto Lunch is enabled and applied on this attendance.", + compute="_compute_duration", ) company_id = fields.Many2one( "res.company", string="Company", related="attendance_sheet_id.company_id" ) auto_lunch_enabled = fields.Boolean( - string="Auto Lunch Enabled", related="company_id.auto_lunch" + string="Auto Lunch Enabled", related="attendance_sheet_config_id.auto_lunch" ) override_auto_lunch = fields.Boolean( string="Override Auto Lunch", @@ -49,6 +50,31 @@ class HrAttendance(models.Model): is set on the department.""", related="department_id.attendance_admin", ) + attendance_sheet_config_id = fields.Many2one( + comodel_name="hr.attendance.sheet.config", + compute="_compute_attendance_sheet_config_id", + ) + + @api.depends( + "check_in", + "check_out", + "employee_id.company_id", + ) + def _compute_attendance_sheet_config_id(self): + for attendance in self: + if attendance.attendance_sheet_id: + config = attendance.attendance_sheet_id.attendance_sheet_config_id + else: + config = self.env["hr.attendance.sheet.config"].search( + [ + ("start_date", "<=", attendance.check_in), + "|", + ("end_date", ">=", attendance.check_out), + ("end_date", "=", False), + ("company_id", "=", attendance.employee_id.company_id.id), + ] + ) + attendance.attendance_sheet_config_id = config # Get Methods def _get_attendance_employee_tz(self, date=None): @@ -88,16 +114,22 @@ def _compute_attendance_sheet_id(self): domain = [("employee_id", "=", attendance.employee_id.id)] if check_in: domain += [ - ("date_start", "<=", check_in), - ("date_end", ">=", check_in), + ("start_date", "<=", check_in), + ("end_date", ">=", check_in), ] attendance_sheet_ids = sheet_obj.search(domain, limit=1) if attendance_sheet_ids.state not in ("locked", "done"): attendance.attendance_sheet_id = attendance_sheet_ids or False + @api.depends( + "check_in", + "check_out", + "employee_id.company_id", + ) def _compute_duration(self): for rec in self: rec.duration = 0.0 + rec.auto_lunch = False if rec.check_in and rec.check_out: delta = rec.check_out - rec.check_in duration = delta.total_seconds() / 3600 @@ -121,38 +153,31 @@ def _compute_duration(self): # If auto lunch is enabled for the company and time between # other attendances < lunch period, then adjust the duration # calculation for the first attendance. + config = rec.attendance_sheet_config_id if ( - rec.company_id.auto_lunch - and total_duration > rec.company_id.auto_lunch_duration != 0.0 + config.auto_lunch + and total_duration > config.auto_lunch_duration != 0.0 and not rec.override_auto_lunch ): first_attendance = self.get_first_attendances(today_attendances) if first_attendance and first_attendance.id == rec.id: if len(today_attendances) > 1: - rec.write({"auto_lunch": False}) time_between_attendances = ( self.compute_time_between_attendances(today_attendances) ) total_time_between_attendances = sum( time_between_attendances ) - if ( - total_time_between_attendances - < rec.company_id.auto_lunch_hours - ): + if total_time_between_attendances < config.auto_lunch_hours: rec.duration = self.compute_rest_of_autolunch( duration, time_between_attendances, - rec.company_id.auto_lunch_hours, + config.auto_lunch_hours, ) - rec.write({"auto_lunch": True}) + rec.auto_lunch = True else: - rec.duration = duration - rec.company_id.auto_lunch_hours - rec.write({"auto_lunch": True}) - else: - rec.write({"auto_lunch": False}) - elif rec.company_id.auto_lunch and rec.auto_lunch: - rec.write({"auto_lunch": False}) + rec.duration = duration - config.auto_lunch_hours + rec.auto_lunch = True def compute_time_between_attendances(self, attendances): previous_attendance = attendances[0] diff --git a/hr_attendance_sheet/models/hr_attendance_sheet.py b/hr_attendance_sheet/models/hr_attendance_sheet.py index a195fb22..71cce8bc 100644 --- a/hr_attendance_sheet/models/hr_attendance_sheet.py +++ b/hr_attendance_sheet/models/hr_attendance_sheet.py @@ -21,8 +21,8 @@ class HrAttendanceSheet(models.Model): user_id = fields.Many2one( "res.users", related="employee_id.user_id", string="User", store=True ) - date_start = fields.Date(string="Date From", required=True, index=True) - date_end = fields.Date(string="Date To", required=True, index=True) + start_date = fields.Date(string="Date From", required=True, index=True) + end_date = fields.Date(string="Date To", required=True, index=True) attendance_ids = fields.One2many( "hr.attendance", "attendance_sheet_id", string="Attendances" ) @@ -80,6 +80,23 @@ class HrAttendanceSheet(models.Model): is set on the department.""", related="department_id.attendance_admin", ) + attendance_sheet_config_id = fields.Many2one( + comodel_name="hr.attendance.sheet.config", + compute="_compute_attendance_sheet_config_id", + ) + + def _compute_attendance_sheet_config_id(self): + for sheet in self: + config = self.env["hr.attendance.sheet.config"].search( + [ + ("start_date", "<=", sheet.start_date), + "|", + ("end_date", ">=", sheet.end_date), + ("end_date", "=", False), + ("company_id", "=", sheet.company_id.id), + ] + ) + sheet.attendance_sheet_config_id = config if config else None def _valid_field_parameter(self, field, name): # I can't even @@ -90,6 +107,7 @@ def activity_update(self): """Activity processing that shows in chatter for approval activity.""" to_clean = self.env["hr.attendance.sheet"] to_do = self.env["hr.attendance.sheet"] + external_id = "hr_attendance_sheet.mail_act_attendance_sheet_approval" for sheet in self: if sheet.state == "draft": to_clean |= sheet @@ -99,87 +117,86 @@ def activity_update(self): ): if sheet.sudo().employee_id.parent_id.user_id.id: sheet.activity_schedule( - "hr_attendance_sheet." "mail_act_attendance_sheet_approval", + external_id, user_id=sheet.sudo().employee_id.parent_id.user_id.id, ) elif sheet.state == "done": to_do |= sheet if to_clean: - to_clean.activity_unlink( - ["hr_attendance_sheet.mail_act_attendance_sheet_approval"] - ) + to_clean.activity_unlink([external_id]) if to_do: - to_do.activity_feedback( - ["hr_attendance_sheet.mail_act_attendance_sheet_approval"] - ) + to_do.activity_feedback([external_id]) # Scheduled Action Methods def _create_sheet_id(self): """Method used by the scheduling action to auto create sheets.""" - companies = ( - self.env["res.company"].search([("use_attendance_sheets", "=", True)]).ids - ) - employees = self.env["hr.employee"].search( - [ - ("use_attendance_sheets", "=", True), - ("company_id", "in", companies), - ("active", "=", True), - ] + companies = self.env["res.company"].search( + [("use_attendance_sheets", "=", True)] ) - for employee in employees: - if not employee.company_id.date_start or not employee.company_id.date_end: - raise UserError( - _( - "Date From and Date To for Attendance \ -must be set on the Company %s" - ) - % employee.company_id.name - ) - sheet = self.env["hr.attendance.sheet"].search( + sheets = self.env["hr.attendance.sheet"] + for company in companies: + employees = self.env["hr.employee"].search( [ - ("employee_id", "=", employee.id), - ("date_start", ">=", employee.company_id.date_start), - ("date_end", "<=", employee.company_id.date_end), + ("use_attendance_sheets", "=", True), + ("company_id", "=", company.id), + ("active", "=", True), ] ) - if not sheet: - self.env["hr.attendance.sheet"].create( - { - "employee_id": employee.id, - "date_start": employee.company_id.date_start, - "date_end": employee.company_id.date_end, - } + last_sheet = sheets.search( + [("company_id", "=", company.id)], limit=1, order="end_date DESC" + ) + next_sheet_date = None + if last_sheet: + next_sheet_date = last_sheet.end_date + relativedelta(days=1) + config = self.env["hr.attendance.sheet.config"].search( + [ + ("start_date", "<=", next_sheet_date), + ("company_id", "=", company.id), + ] ) - self.check_pay_period_dates() - - def check_pay_period_dates(self): - companies = self.env["res.company"].search( - [("use_attendance_sheets", "!=", False)] - ) - for company_id in companies: - if company_id.date_end and datetime.today().date() > company_id.date_end: - company_id.date_start = company_id.date_end + relativedelta(days=1) - company_id.set_date_end(company_id.id) + else: + config = self.env["hr.attendance.sheet.config"].search( + [ + ("company_id", "=", company.id), + ], + limit=1, + order="end_date DESC", + ) + if not config: + continue + if not next_sheet_date: + next_sheet_date = config.start_date + if next_sheet_date <= fields.Date.today(): + for employee in employees: + sheet = self.env["hr.attendance.sheet"].create( + { + "employee_id": employee.id, + "start_date": next_sheet_date, + "end_date": config.compute_end_date(next_sheet_date), + } + ) + sheets += sheet + return sheets # Compute Methods - @api.depends("employee_id", "date_start", "date_end") + @api.depends("employee_id", "start_date", "end_date") def _compute_name(self): for sheet in self: sheet.name = False - if sheet.employee_id and sheet.date_start and sheet.date_end: + if sheet.employee_id and sheet.start_date and sheet.end_date: sheet.name = ( sheet.employee_id.name + " (" + str( datetime.strptime( - str(sheet.date_start), DEFAULT_SERVER_DATE_FORMAT + str(sheet.start_date), DEFAULT_SERVER_DATE_FORMAT ).strftime("%m/%d/%y") ) + " - " + str( datetime.strptime( - str(sheet.date_end), DEFAULT_SERVER_DATE_FORMAT + str(sheet.end_date), DEFAULT_SERVER_DATE_FORMAT ).strftime("%m/%d/%y") ) + ")" @@ -274,13 +291,13 @@ def create(self, vals): [ ("employee_id", "=", res.employee_id.id), ("attendance_sheet_id", "=", False), - ("check_in", ">=", res.date_start), - ("check_in", "<=", res.date_end), + ("check_in", ">=", res.start_date), + ("check_in", "<=", res.end_date), "|", ("check_out", "=", False), "&", - ("check_out", ">=", res.date_start), - ("check_out", "<=", res.date_end), + ("check_out", ">=", res.start_date), + ("check_out", "<=", res.end_date), ] ) attendances._compute_attendance_sheet_id() @@ -292,8 +309,8 @@ def write(self, values): "employee_id", "name", "attendance_ids", - "date_start", - "date_end", + "start_date", + "end_date", ] for record in self: if record.state == "locked" and any( @@ -321,8 +338,8 @@ def action_attendance_sheet_confirm(self): lambda att: att.check_in and not att.check_out ) if not ids_not_checkout: - self.write({"state": "confirm"}) - self.activity_update() + sheet.write({"state": "confirm"}) + sheet.activity_update() else: raise UserError( _( @@ -344,27 +361,23 @@ def action_attendance_sheet_done(self): """Approve button.""" if self.filtered(lambda sheet: sheet.state != "confirm"): raise UserError(_("Cannot approve a non-submitted sheet.")) - for _sheet in self: - reviewer = self.env["hr.employee"].search( - [("user_id", "=", self.env.user.id)], limit=1 - ) - if not reviewer: - raise UserError( - _( - """In order to review a attendance sheet, - your user needs to be linked to an employee record.""" - ) - ) - else: - self._check_can_review() - self.write( - { - "state": "done", - "reviewer_id": reviewer.id, - "reviewed_on": fields.Datetime.now(), - } + reviewer = self.env.user.employee_id + if not reviewer: + raise UserError( + _( + """In order to review a attendance sheet, + your user needs to be linked to an employee record.""" ) - self.activity_update() + ) + self._check_can_review() + self.write( + { + "state": "done", + "reviewer_id": reviewer.id, + "reviewed_on": fields.Datetime.now(), + } + ) + self.activity_update() return True def action_attendance_sheet_lock(self): diff --git a/hr_attendance_sheet/models/hr_attendance_sheet_config.py b/hr_attendance_sheet/models/hr_attendance_sheet_config.py new file mode 100644 index 00000000..18cd0dc2 --- /dev/null +++ b/hr_attendance_sheet/models/hr_attendance_sheet_config.py @@ -0,0 +1,89 @@ +# Copyright 2020 Pavlov Media +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class HrAttendanceConfig(models.Model): + _name = "hr.attendance.sheet.config" + _description = "Configuration for Attendance Sheet Generation" + + period = fields.Selection( + selection=[ + ("MONTHLY", "Month"), + ("BIWEEKLY", "Bi-Week"), + ("WEEKLY", "Week"), + ("DAILY", "Day"), + ], + string="Attendance Sheet Range", + default="WEEKLY", + help="The range of your Attendance Sheet.", + required=True, + ) + + start_date = fields.Date( + string="Date From", + required=True, + ) + end_date = fields.Date( + string="Date To", + ) + + auto_lunch = fields.Boolean( + string="Auto Lunch", + help="Applies a lunch period if duration is over the max time.", + ) + + auto_lunch_duration = fields.Float( + string="Duration", + help="The duration on an attendance that would trigger an auto lunch.", + ) + + auto_lunch_hours = fields.Float( + string="Lunch Hours", + help="Enter the lunch period that would be used for an auto lunch.", + ) + + company_id = fields.Many2one( + comodel_name="res.company", + required=True, + default=lambda self: self.env.company, + ) + + _sql_constraints = [ + ( + "check_date", + "CHECK(start_date<=end_date)", + _("Start_Date must be before to end_date"), + ), + ] + + @api.constrains("start_date", "end_date", "company_id") + def check_dates(self): + for config in self: + existing_config = self.search( + [ + ("start_date", "<", config.end_date), + ("end_date", ">", config.start_date), + ("company_id", "=", config.company_id.id), + ("id", "!=", config.id), + ], + limit=1, + ) + if existing_config: + raise ValidationError( + _("There is already a Sheet Configuration for that period") + ) + + def compute_end_date(self, start_date): + self.ensure_one() + if self.period == "DAILY": + return start_date + relativedelta(days=1) + if self.period == "WEEKLY": + return start_date + relativedelta(days=6) + if self.period == "BIWEEKLY": + return start_date + relativedelta(days=13) + return start_date + relativedelta(months=1, day=1, days=-1) diff --git a/hr_attendance_sheet/models/res_company.py b/hr_attendance_sheet/models/res_company.py index 5460b273..4c5c5241 100644 --- a/hr_attendance_sheet/models/res_company.py +++ b/hr_attendance_sheet/models/res_company.py @@ -1,75 +1,13 @@ # Copyright 2020 Pavlov Media # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from datetime import datetime - -from dateutil.relativedelta import relativedelta - -from odoo import api, fields, models +from odoo import fields, models class ResCompany(models.Model): _inherit = "res.company" use_attendance_sheets = fields.Boolean("Use Attendance Sheets", default=False) - attendance_sheet_range = fields.Selection( - selection=[ - ("MONTHLY", "Month"), - ("BIWEEKLY", "Bi-Week"), - ("WEEKLY", "Week"), - ("DAILY", "Day"), - ], - string="Attendance Sheet Range", - default="WEEKLY", - help="The range of your Attendance Sheet.", - ) - - @api.onchange("attendance_sheet_range") - def onchange_attendance_sheet_range(self): - if self.attendance_sheet_range == "MONTHLY": - self._origin.write({"date_start": datetime.today().date().replace(day=1)}) - - date_start = fields.Date( - string="Date From", index=True, default=datetime.today().date() - ) - date_end = fields.Date(string="Date To", readonly=True, index=True) - - def set_date_end(self, company): - company = self.browse(company) - if company.date_start: - if company.attendance_sheet_range == "WEEKLY": - return company.date_start + relativedelta(days=6) - elif company.attendance_sheet_range == "BIWEEKLY": - return company.date_start + relativedelta(days=13) - else: - return company.date_start + relativedelta(months=1, day=1, days=-1) - - def write(self, vals): - res = super().write(vals) - if vals.get("date_start") or vals.get("attendance_sheet_range"): - vals.update({"date_end": self.set_date_end(company=self.id)}) - return res - - @api.model - def create(self, vals): - res = super().create(vals) - if vals.get("date_start"): - res.write({"date_end": self.set_date_end(res.id)}) - return res - - attendance_week_start = fields.Selection( - selection=[ - ("0", "Monday"), - ("1", "Tuesday"), - ("2", "Wednesday"), - ("3", "Thursday"), - ("4", "Friday"), - ("5", "Saturday"), - ("6", "Sunday"), - ], - string="Week Starting Day", - default="0", - ) attendance_sheet_review_policy = fields.Selection( string="Attendance Sheet Review Policy", @@ -82,17 +20,8 @@ def create(self, vals): help="How Attendance Sheets review is performed.", ) - auto_lunch = fields.Boolean( - string="Auto Lunch", - help="Applies a lunch period if duration is over the max time.", - ) - - auto_lunch_duration = fields.Float( - string="Duration", - help="The duration on an attendance that would trigger an auto lunch.", - ) - - auto_lunch_hours = fields.Float( - string="Lunch Hours", - help="Enter the lunch period that would be used for an auto lunch.", + attendance_sheet_config_ids = fields.One2many( + comodel_name="hr.attendance.sheet.config", + inverse_name="company_id", + string="Attendances Sheet Config", ) diff --git a/hr_attendance_sheet/models/res_config.py b/hr_attendance_sheet/models/res_config.py index d25e9059..059df462 100644 --- a/hr_attendance_sheet/models/res_config.py +++ b/hr_attendance_sheet/models/res_config.py @@ -7,41 +7,6 @@ class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" - attendance_sheet_range = fields.Selection( - related="company_id.attendance_sheet_range", - string="Attendance Sheet Range", - help="The range of your Attendance Sheet.", - readonly=False, - ) - - attendance_week_start = fields.Selection( - related="company_id.attendance_week_start", - string="Week Starting Day", - help="Starting day for Attendance Sheets.", - readonly=False, - ) - attendance_sheet_review_policy = fields.Selection( related="company_id.attendance_sheet_review_policy", readonly=False ) - - auto_lunch = fields.Boolean( - string="Auto Lunch", - related="company_id.auto_lunch", - help="Forces a lunch period if duration is over the max time.", - readonly=False, - ) - - auto_lunch_duration = fields.Float( - string="Duration", - related="company_id.auto_lunch_duration", - help="The duration on an attendance that would trigger an auto lunch.", - readonly=False, - ) - - auto_lunch_hours = fields.Float( - string="Lunch Hours", - related="company_id.auto_lunch_hours", - help="Enter the lunch period that would be used for an auto lunch.", - readonly=False, - ) diff --git a/hr_attendance_sheet/security/hr_attendance_sheet_config_acl.xml b/hr_attendance_sheet/security/hr_attendance_sheet_config_acl.xml new file mode 100644 index 00000000..c214f6bb --- /dev/null +++ b/hr_attendance_sheet/security/hr_attendance_sheet_config_acl.xml @@ -0,0 +1,23 @@ + + + + + hr.attendance.sheet.config access user + + + + + + + + + hr.attendance.sheet.config access manager + + + + + + + + diff --git a/hr_attendance_sheet/tests/test_attendance_sheet.py b/hr_attendance_sheet/tests/test_attendance_sheet.py index 8f9be1a6..5b919792 100644 --- a/hr_attendance_sheet/tests/test_attendance_sheet.py +++ b/hr_attendance_sheet/tests/test_attendance_sheet.py @@ -15,6 +15,7 @@ class TestAttendanceSheet(TransactionCase): def setUp(self): super().setUp() self.AttendanceSheet = self.env["hr.attendance.sheet"] + self.AttendanceSheetConfig = self.env["hr.attendance.sheet.config"] self.ctx_new_test_user = { "mail_create_nolog": True, "mail_create_nosubscribe": True, @@ -88,11 +89,16 @@ def test_attendance_sheet(self): company.write( { "use_attendance_sheets": True, - "date_start": "2023-02-01", - "attendance_sheet_range": "WEEKLY", + } + ) + self.AttendanceSheetConfig.create( + { + "start_date": "2023-02-01", + "period": "WEEKLY", "auto_lunch": True, "auto_lunch_duration": 0.5, "auto_lunch_hours": 0.5, + "company_id": company.id, } ) @@ -100,8 +106,8 @@ def test_attendance_sheet(self): view_id = "hr_attendance_sheet.hr_attendance_sheet_view_form" with Form(self.AttendanceSheet, view=view_id) as f: f.employee_id = self.test_employee - f.date_start = "2023-01-01" - f.date_end = "2023-01-15" + f.start_date = "2023-01-01" + f.end_date = "2023-01-15" sheet = f.save() sheet.attendance_action_change() time.sleep(2) @@ -192,7 +198,7 @@ def test_attendance_sheet(self): # TEST11: Test write sheet when locked with self.assertRaises(UserError): - sheet.with_user(self.test_user_employee).write({"date_start": "2023-02-01"}) + sheet.with_user(self.test_user_employee).write({"start_date": "2023-02-01"}) # TEST12: Test error trying to write attendance with self.assertRaises(UserError): @@ -225,41 +231,29 @@ def test_attendance_sheet(self): # sheet.with_user(self.test_user_manager).action_attendance_sheet_refuse() self.assertEqual(sheet.state, "draft") - # TEST19: Set company date range to bi-weekly - company.write({"attendance_sheet_range": "BIWEEKLY"}) - self.assertFalse(company.date_end) - - # TEST20: Test autolunch on attendance - # clock_date = fields.Date.today() + timedelta(days=2) - # with self.assertRaises(UserError): - # self.test_attendance4 = self.env["hr.attendance"].create( - # { - # "employee_id": self.test_employee1.id, - # "check_out": clock_date.strftime("%Y-%m-11 16:00"), - # } - # ) - # self.assertEqual(self.test_attendance4.auto_lunch, False) - def test_company_create_sheet_id(self): company = self.env.company company.write({"use_attendance_sheets": True}) # TEST21: Scheduled Action No Company Start/End Date Error - with self.assertRaises(UserError): - self.AttendanceSheet._create_sheet_id() + res = self.AttendanceSheet._create_sheet_id() + self.assertFalse(res) # TEST22: Company Start/End Date onchange company = self.env.company company.write( { - "attendance_sheet_range": "WEEKLY", "attendance_sheet_review_policy": "employee_manager", - "date_start": "2023-02-01", - "date_end": "2023-02-15", } ) - company.onchange_attendance_sheet_range() - self.assertEqual(company.date_start, fields.Date.from_string("2023-02-01")) + self.AttendanceSheetConfig.create( + { + "start_date": "2023-02-01", + "end_date": "2023-02-15", + "period": "WEEKLY", + "company_id": company.id, + } + ) # TEST23: Create Sheets Cron Method # self.AttendanceSheet._create_sheet_id() @@ -280,46 +274,6 @@ def test_company_create_sheet_id(self): sheet.with_user(self.test_user_employee1).action_attendance_sheet_confirm() self.assertFalse(sheet.state) - def test_company_create(self): - # TEST27: Create Company - company = self.test_company = self.env["res.company"].create( - { - "name": "Test Company", - "date_start": "2023-02-01", - "attendance_sheet_range": "BIWEEKLY", - } - ) - self.assertEqual(company.date_end, fields.Date.from_string("2023-02-14")) - - def test_company_start_end_date_change(self): - # TEST28: Test changing start/end date on company via cron - company = self.env.company - company.write( - { - "date_start": "2023-01-25", - "date_end": "2023-02-01", - "use_attendance_sheets": True, - "attendance_sheet_range": "WEEKLY", - "attendance_sheet_review_policy": "employee_manager", - } - ) - self.AttendanceSheet._create_sheet_id() - self.assertEqual(company.date_end, fields.Date.from_string("2023-02-01")) - - def test_set_date_end(self): - # TEST29: Create Company and test else statement in set end date - company = self.test_company = self.env["res.company"].create( - { - "name": "Test Company", - "date_start": "2023-02-01", - "attendance_sheet_range": "DAILY", - } - ) - self.assertEqual( - company.date_end, - fields.Date.from_string("2023-02-28"), - ) - def test_access_errors(self): self.test_user_basic = new_test_user( self.env, @@ -349,8 +303,8 @@ def test_access_errors(self): view_id = "hr_attendance_sheet.hr_attendance_sheet_view_form" with Form(self.AttendanceSheet, view=view_id) as f: f.employee_id = self.test_basic_employee - f.date_start = "2023-02-01" - f.date_end = "2023-02-15" + f.start_date = "2023-02-01" + f.end_date = "2023-02-15" sheet = f.save() sheet.action_attendance_sheet_confirm() @@ -390,11 +344,16 @@ def test_auto_lunch_scenario(self): company.write( { "use_attendance_sheets": True, + } + ) + self.AttendanceSheetConfig.create( + { + "start_date": "2023-02-01", + "end_date": "2023-02-8", + "company_id": company.id, "auto_lunch": True, "auto_lunch_duration": 5, "auto_lunch_hours": 0.5, - "date_start": "2023-02-01", - "date_end": "2023-02-08", } ) self.AttendanceSheet._create_sheet_id() @@ -403,10 +362,9 @@ def test_auto_lunch_scenario(self): "employee_id": self.test_employee.id, "check_in": "2023-01-15 08:00", "check_out": "2023-01-15 12:00", - "auto_lunch": True, } ) - self.assertTrue(self.test_attendance_no_lunch.auto_lunch) + self.assertFalse(self.test_attendance_no_lunch.auto_lunch) # TEST35: clock-in button method on sheet sheet = self.env["hr.attendance.sheet"].search([], limit=1) @@ -421,8 +379,13 @@ def test_attendance_admin(self): { "use_attendance_sheets": True, "attendance_sheet_review_policy": "employee_manager", - "date_start": "2023-02-01", - "date_end": "2023-02-08", + } + ) + self.AttendanceSheetConfig.create( + { + "start_date": "2023-02-01", + "end_date": "2023-02-8", + "company_id": company.id, } ) self.test_admin = self.env["hr.employee"].create({"name": "TestAdmin"}) @@ -457,11 +420,17 @@ def test_auto_lunch_time_between_too_small_scenario(self): company.write( { "use_attendance_sheets": True, + } + ) + self.AttendanceSheetConfig.create( + { + "start_date": "2023-01-01", + "end_date": "2023-01-15", + "period": "WEEKLY", + "company_id": company.id, "auto_lunch": True, "auto_lunch_duration": 5, "auto_lunch_hours": 1, - "date_start": "2023-01-01", - "date_end": "2023-01-15", } ) self.AttendanceSheet._create_sheet_id() @@ -498,11 +467,11 @@ def test_action_attendance_sheet_confirm(self): "hours_to_work": 20.0, } ) - attednance_sheet = self.env["hr.attendance.sheet"].create( + attendance_sheet = self.env["hr.attendance.sheet"].create( { "employee_id": self.test_employee_test.id, - "date_start": "2023-02-01", - "date_end": "2023-02-08", + "start_date": "2023-02-01", + "end_date": "2023-02-08", "attendance_ids": [ ( 0, @@ -516,26 +485,33 @@ def test_action_attendance_sheet_confirm(self): } ) with self.assertRaises(UserError): - attednance_sheet.with_user( + attendance_sheet.with_user( self.test_user_employee_test ).action_attendance_sheet_confirm() company = self.env.company company.write({"attendance_sheet_review_policy": "hr_or_manager"}) - attednance_sheet.write({"can_review": False}) + attendance_sheet.write({"can_review": False}) with self.assertRaises(UserError): - attednance_sheet._check_can_review() + attendance_sheet._check_can_review() company.write( { "use_attendance_sheets": True, - "date_end": "2023-01-31", } ) - attednance_sheet.check_pay_period_dates() - attednance_sheet_check_out = self.env["hr.attendance.sheet"].create( + self.AttendanceSheetConfig.create( + { + "start_date": "2023-01-01", + "end_date": "2023-01-31", + "period": "WEEKLY", + "company_id": company.id, + } + ) + + attendance_sheet_check_out = self.env["hr.attendance.sheet"].create( { "employee_id": self.test_employee_test.id, - "date_start": "2023-02-01", - "date_end": "2023-02-08", + "start_date": "2023-02-01", + "end_date": "2023-02-08", "attendance_ids": [ ( 0, @@ -549,14 +525,14 @@ def test_action_attendance_sheet_confirm(self): ], } ) - attednance_sheet_check_out.with_user( + attendance_sheet_check_out.with_user( self.test_user_employee_test ).action_attendance_sheet_confirm() - attednance_sheet = self.env["hr.attendance.sheet"].create( + attendance_sheet = self.env["hr.attendance.sheet"].create( { "employee_id": self.test_employee_test.id, - "date_start": "2023-02-01", - "date_end": "2023-02-08", + "start_date": "2023-02-01", + "end_date": "2023-02-08", "attendance_ids": [ ( 0, @@ -570,15 +546,15 @@ def test_action_attendance_sheet_confirm(self): ], } ) - attednance_sheet.with_user( + attendance_sheet.with_user( self.test_user_employee_test ).action_attendance_sheet_confirm() - attednance_sheet_check_out = self.env["hr.attendance.sheet"].create( + attendance_sheet_check_out = self.env["hr.attendance.sheet"].create( { "employee_id": self.test_employee_test.id, - "date_start": "2023-02-01", - "date_end": "2023-02-08", + "start_date": "2023-02-01", + "end_date": "2023-02-08", "attendance_ids": [ ( 0, @@ -592,18 +568,18 @@ def test_action_attendance_sheet_confirm(self): ], } ) - attednance_sheet_check_out.with_user( + attendance_sheet_check_out.with_user( self.test_user_employee_test ).action_attendance_sheet_confirm() sheet = self.env["hr.attendance.sheet"].create( { "employee_id": self.test_employee_test.id, - "date_start": "2023-02-01", - "date_end": "2023-02-08", + "start_date": "2023-02-01", + "end_date": "2023-02-08", } ) vals = { - "employee_id": attednance_sheet_check_out.employee_id.id, + "employee_id": attendance_sheet_check_out.employee_id.id, "check_in": "2023-02-01 17:00", "check_out": "2023-02-01 18:00", } @@ -621,8 +597,13 @@ def test_action_attendance_sheet_confirm(self): company.write( { "use_attendance_sheets": True, - "date_start": "2023-02-01", - "attendance_sheet_range": "WEEKLY", + } + ) + config = self.AttendanceSheetConfig.create( + { + "start_date": "2023-02-01", + "period": "WEEKLY", + "company_id": company.id, "auto_lunch": True, "auto_lunch_duration": 0.5, "auto_lunch_hours": 6.0, @@ -660,12 +641,8 @@ def test_action_attendance_sheet_confirm(self): ) self.attendance_lunch_3._compute_duration() - company.write( + config.write( { - "use_attendance_sheets": True, - "date_start": "2023-02-01", - "attendance_sheet_range": "WEEKLY", - "auto_lunch": True, "auto_lunch_duration": 0.5, "auto_lunch_hours": 3.0, } @@ -679,12 +656,8 @@ def test_action_attendance_sheet_confirm(self): ) self.attendance_lunch._compute_duration() - company.write( + config.write( { - "use_attendance_sheets": True, - "date_start": "2023-02-01", - "attendance_sheet_range": "WEEKLY", - "auto_lunch": True, "auto_lunch_duration": 0.5, "auto_lunch_hours": 3.0, } @@ -698,13 +671,3 @@ def test_action_attendance_sheet_confirm(self): } ) self.attendance_lunch_1._compute_duration() - - company.write( - { - "attendance_sheet_range": "MONTHLY", - "attendance_sheet_review_policy": "employee_manager", - "date_start": "2023-02-01", - "date_end": "2023-02-15", - } - ) - company.onchange_attendance_sheet_range() diff --git a/hr_attendance_sheet/views/hr_attendance_sheet.xml b/hr_attendance_sheet/views/hr_attendance_sheet.xml index 46dc244f..b90ea1d7 100644 --- a/hr_attendance_sheet/views/hr_attendance_sheet.xml +++ b/hr_attendance_sheet/views/hr_attendance_sheet.xml @@ -85,8 +85,8 @@ - - + + @@ -121,6 +121,7 @@ @@ -188,13 +189,13 @@ diff --git a/hr_attendance_sheet/views/hr_attendance_sheet_config.xml b/hr_attendance_sheet/views/hr_attendance_sheet_config.xml new file mode 100644 index 00000000..b7e96b21 --- /dev/null +++ b/hr_attendance_sheet/views/hr_attendance_sheet_config.xml @@ -0,0 +1,73 @@ + + + + + hr.attendance.sheet.config.form + hr.attendance.sheet.config + +
+ + + + + + + + + + + + + + + +
+
+
+ + hr.attendance.sheet.config.tree + hr.attendance.sheet.config + + + + + + + + + + + + + + Attendance Sheet Configuration + ir.actions.act_window + hr.attendance.sheet.config + tree,form + + + +
diff --git a/hr_attendance_sheet/views/hr_attendance_view.xml b/hr_attendance_sheet/views/hr_attendance_view.xml index 52600b7e..c5da66fe 100644 --- a/hr_attendance_sheet/views/hr_attendance_view.xml +++ b/hr_attendance_sheet/views/hr_attendance_view.xml @@ -7,7 +7,7 @@ - + diff --git a/hr_attendance_sheet/views/res_company.xml b/hr_attendance_sheet/views/res_company.xml index c463d670..2609b2b1 100644 --- a/hr_attendance_sheet/views/res_company.xml +++ b/hr_attendance_sheet/views/res_company.xml @@ -7,18 +7,6 @@ - - -
diff --git a/hr_attendance_sheet/views/res_config_settings_views.xml b/hr_attendance_sheet/views/res_config_settings_views.xml index b9675868..def321a1 100644 --- a/hr_attendance_sheet/views/res_config_settings_views.xml +++ b/hr_attendance_sheet/views/res_config_settings_views.xml @@ -15,59 +15,6 @@ name="hr_attendance_sheet" id="hr_attendance_sheet" > -
-
-
-
-
-
-
-
-

Attendance Rules

-
-
-
- -
-
-
-
-
diff --git a/hr_attendance_sheet_compensatory/README.rst b/hr_attendance_sheet_compensatory/README.rst new file mode 100644 index 00000000..72dc61d0 --- /dev/null +++ b/hr_attendance_sheet_compensatory/README.rst @@ -0,0 +1,125 @@ +================================ +HR Attendance Sheet Compensatory +================================ + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr--attendance-lightgray.png?logo=github + :target: https://github.com/OCA/hr-attendance/tree/14.0/hr_attendance_sheet_compensatory + :alt: OCA/hr-attendance +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-attendance-14-0/hr-attendance-14-0-hr_attendance_sheet_compensatory + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/hr-attendance&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This is based on `hr_attendance_sheet` which add a validation mechanism +to review employee attendance with an auto-lunch calculation. +This allows to generate compensatory hours (allocated leaves) that can +be used later as day off or to regulate credits leaves as +this module is compatible with `hr_holidays_credit` module. + +This is based on the `hr_attendance_overtime` module which +mark rows as "overtime" those rows are not due by default +as it could came from possible mist check-out. So manager can +decide to add or not those overtime attendance lines or not and +compute or adjust compensatory/leaves hours to generate. + +This module is **incompatible** with hr_attendance_validation +because it implements the same feature and especially generates +errors during hr.attendance validation + +..note:: + + If you are allowing flexible hours - check-in/check-out range + are bigger than average hours per day - So you can generate + compensatory hours from lines that are not marked as overtime. + +Once review is validated attendance lines are locked on that period. + +At the end managers can check holidays allocation per year and +by employee to make sure allowed employee compensatory hours are +not over. + +Employees can: +- access to validated sheets to review hours taken account +- see current week hours on check-in view + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +* Ensure employee weeks are properly set +* Set the leave type to use by generating compensatory + hours from attendance review (to be done in hr attendance configuration) +* once all leaves and attendances has been recorded you can generate leave reviews + by setting up a cron job running every monday morning to generate the previous week + with the following code on `hr.attendance.sheet` model:: + + model.generate_reviews() + +Usage +===== + +* Once review has been generate by ir cron manager are able to + open each one on Attenances > Manager > Attendance validation +* On each form decide if recorded overtime are due or not +* change the amount of allocated compensatory hours to generate +* validate the review to generate the allocation + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Odoo S.A. + +Contributors +~~~~~~~~~~~~ + +* Pierre Verkest +* Maxime Franco + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/hr-attendance `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_attendance_sheet_compensatory/__init__.py b/hr_attendance_sheet_compensatory/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/hr_attendance_sheet_compensatory/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_attendance_sheet_compensatory/__manifest__.py b/hr_attendance_sheet_compensatory/__manifest__.py new file mode 100644 index 00000000..737eb974 --- /dev/null +++ b/hr_attendance_sheet_compensatory/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +{ + "name": "HR Attendance Sheet Compensatory", + "version": "14.0.1.0.3", + "category": "Human Resources", + "summary": "Group attendances into attendance sheets.", + "website": "https://github.com/OCA/hr-attendance", + "author": "Odoo S.A., Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + "depends": [ + "hr_attendance", + "hr_attendance_sheet", + "hr_attendance_overtime", + "hr_attendance_reason", + "hr_attendance_modification_tracking", + "hr_holidays", + ], + "data": [ + "security/hr_attendance_sheet_rule.xml", + "views/assets.xml", + "views/hr_attendance.xml", + "views/hr_attendance_sheet.xml", + "views/res_config_settings_views.xml", + ], +} diff --git a/hr_attendance_sheet_compensatory/models/__init__.py b/hr_attendance_sheet_compensatory/models/__init__.py new file mode 100644 index 00000000..48c62588 --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/__init__.py @@ -0,0 +1,7 @@ +from . import hr_attendance +from . import hr_attendance_sheet +from . import hr_employee +from . import hr_leave +from . import res_company +from . import res_config +from . import resource_calendar diff --git a/hr_attendance_sheet_compensatory/models/hr_attendance.py b/hr_attendance_sheet_compensatory/models/hr_attendance.py new file mode 100644 index 00000000..7eb58d58 --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/hr_attendance.py @@ -0,0 +1,98 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import SUPERUSER_ID, _, api, fields, models +from odoo.exceptions import ValidationError + + +class HrAttendance(models.Model): + _inherit = "hr.attendance" + + is_overtime_due = fields.Boolean( + string="Is overtime due", + default=False, + help="Whether the overtime is due or not. " + "By default overtime is not due until a manager validates it.", + ) + + @api.model_create_multi + def create(self, vals_list): + attendances = super().create(vals_list) + for attendance in attendances: + if attendance._is_validated_employee_week(): + raise ValidationError( + _( + "Cannot create new attendance for employee %s. " + "Attendance for the day of the check in %s " + "has already been reviewed and validated." + ) + % ( + attendance.employee_id.name, + attendance.check_in.date(), + ) + ) + return attendances + + def unlink(self): + for record in self: + if record.attendance_sheet_id.state == "done": + raise ValidationError( + _( + "Can not remove this attendance (%s, %s) " + "which has been already reviewed and validated." + ) + % ( + record.employee_id.name, + record.check_in.date(), + ) + ) + return super().unlink() + + def write(self, vals): + allowed_fields = self.get_allowed_fields() + is_allowed_fields = allowed_fields.issuperset(vals.keys()) + for record in self: + if record.attendance_sheet_id.state == "done" and not is_allowed_fields: + raise ValidationError( + _( + "Can not change this attendance (%s, %s) " + "which has been already reviewed and validated." + ) + % ( + record.employee_id.name, + record.check_in.date(), + ) + ) + res = super().write(vals) + for record in self: + if record._is_validated_employee_week() and not is_allowed_fields: + raise ValidationError( + _( + "Can not change this attendance (%s, %s) " + "which would be moved to a validated day." + ) + % ( + record.employee_id.name, + record.check_in.date(), + ) + ) + return res + + def _is_validated_employee_week(self): + validated_week = ( + self.env["hr.attendance.sheet"] + .with_user(SUPERUSER_ID) + .search_count( + [ + ("employee_id", "=", self.employee_id.id), + ("state", "=", "done"), + ("start_date", "<=", self.check_in.date()), + ("end_date", ">=", self.check_in.date()), + ] + ) + ) + return validated_week > 0 + + def get_allowed_fields(self): + return {"auto_lunch"} diff --git a/hr_attendance_sheet_compensatory/models/hr_attendance_sheet.py b/hr_attendance_sheet_compensatory/models/hr_attendance_sheet.py new file mode 100644 index 00000000..2ac234b4 --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/hr_attendance_sheet.py @@ -0,0 +1,333 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.osv import expression + + +class HrAttendanceSheet(models.Model): + _inherit = "hr.attendance.sheet" + + theoretical_hours = fields.Float( + string="Theoretical (hours)", + related="working_hours.total_hours", + help="Theoretical calendar hours to spend by week.", + ) + attendance_due_ids = fields.One2many( + "hr.attendance", + compute="_compute_attendance_due_ids", + string="Valid attendances", + ) + leave_ids = fields.Many2many("hr.leave", string="Leaves") + leave_allocation_id = fields.Many2one( + "hr.leave.allocation", + string="Leave allocation", + help="Automatically generated on validation if compensatory_hour > 0", + ) + leave_id = fields.Many2one( + "hr.leave", + string="Leave", + help="Automatically generated on validation if " + "regularization_compensatory_hour_taken > 0", + ) + leave_hours = fields.Float( + "Leaves (hours)", + compute="_compute_leaves", + help="Compute number of leaves in hours", + ) + attendance_full_hours = fields.Float( + "Attendance (Full hours)", + compute="_compute_attendances_hours", + help="Compute number of attendance lines", + ) + attendance_hours = fields.Float( + "Attendance without overtime", + compute="_compute_attendances_hours", + help="Compute number of attendance lines not marked as overtime", + ) + attendance_total_hours = fields.Float( + "Total Attendance (without lunch - Not Due)", + compute="_compute_attendances_hours", + help="Validated attendances. Sum attendance and due overtime lines.", + ) + overtime_due_hours = fields.Float( + "Overtime due (hours)", + compute="_compute_attendances_hours", + help="Compute number of attendance lines marked as overtime which are marked as due", + ) + overtime_not_due_hours = fields.Float( + "Overtime not due (hours)", + compute="_compute_attendances_hours", + help="Compute number of attendance lines marked as overtime which are not due", + ) + compensatory_hour = fields.Float( + "Compensatory hour", + help="Compensatory hours that will be allocated to the employee.", + ) + regularization_compensatory_hour_taken = fields.Float( + "Regularization compensatory hours' taken", + help="Compensatory hours that will be counted as leaves for the employee.", + ) + require_regeneration = fields.Boolean( + "Require regeneration", + default=False, + help="Couldn't properly call action retrieve lines in onchange " + "instead alert user to click on it when needs.", + ) + start_date = fields.Date( + default=lambda self: self._default_start_date(), + ) + end_date = fields.Date( + default=lambda self: self._default_end_date(), + ) + + def _default_start_date(self): + """returns the monday before last sunday""" + today = fields.Date.today() + return today - timedelta(days=today.weekday() + 7) + + def _default_end_date(self): + """returns last sunday""" + today = fields.Date.today() + return today - timedelta(days=today.weekday() + 1) + + @api.depends("leave_ids") + def _compute_leaves(self): + for record in self: + leave_hours = 0 + for leave in record.leave_ids: + if leave.request_unit_half or leave.request_unit_hours: + # we assume time off is recorded by hours + leave_hours += leave.sudo().number_of_hours_display + else: + # As far leaves can be record on multiple weeks + # intersect calendar attendance and leaves date + # to compute theoretical leave time + current_date = max(leave.request_date_from, record.start_date) + date_to = min( + leave.request_date_to or leave.request_date_from, + record.end_date, + ) + while current_date <= date_to: + leave_hours += sum( + record.working_hours.attendance_ids.filtered( + lambda att: int(att.dayofweek) == current_date.weekday() + ).mapped(lambda att: att.hour_to - att.hour_from) + ) + current_date += timedelta(days=1) + + record.leave_hours = leave_hours + + @api.depends("attendance_ids", "attendance_ids.is_overtime") + def _compute_attendances_hours(self): + for record in self: + record.attendance_full_hours = sum( + record.attendance_ids.mapped("worked_hours") + ) + record.attendance_hours = sum( + record.attendance_ids.filtered(lambda att: not att.is_overtime).mapped( + "duration" + ) + ) + record.overtime_due_hours = sum( + record.attendance_ids.filtered( + lambda att: att.is_overtime and att.is_overtime_due + ).mapped("duration") + ) + record.overtime_not_due_hours = sum( + record.attendance_ids.filtered( + lambda att: att.is_overtime and not att.is_overtime_due + ).mapped("duration") + ) + record.attendance_total_hours = sum( + record.attendance_due_ids.mapped("duration") + ) + + def _compute_attendance_due_ids(self): + for record in self: + record.attendance_due_ids = record.attendance_ids.filtered( + lambda att: not att.is_overtime or att.is_overtime_due + ) + + @api.onchange("employee_id", "start_date", "end_date") + def _onchange_recompute_lines(self): + self.ensure_one() + self.require_regeneration = True + + def _retrieve_attendance(self): + """Method that link to hr.attendance between date from and date to""" + HrAttendance = self.env["hr.attendance"] + for record in self: + record.attendance_ids = HrAttendance.search( + [ + ("employee_id", "=", record.employee_id.id), + ("check_in", ">=", record.start_date), + ("check_in", "<=", record.end_date), + ], + ) + + def _retrieve_leave(self): + """Method that link to hr.leave between date from and date to""" + HrLeave = self.env["hr.leave"] + for record in self: + domain = expression.AND( + [ + [ + ("state", "in", ["validate", "validate1"]), + ("employee_id", "=", record.employee_id.id), + ], + expression.OR( + [ + # leaves thats starts in the validation sheet interval + expression.AND( + [ + [("request_date_from", ">=", record.start_date)], + [("request_date_from", "<=", record.end_date)], + ] + ), + # leaves thats ends in the validation sheet interval + expression.AND( + [ + [("request_date_to", ">=", record.start_date)], + [("request_date_to", "<=", record.end_date)], + ] + ), + # leaves thats start before and ends after the validation sheet + expression.AND( + [ + [("request_date_from", "<", record.start_date)], + [("request_date_to", ">", record.end_date)], + ] + ), + ] + ), + ] + ) + record.leave_ids = HrLeave.search(domain) + + def action_retrieve_attendance_and_leaves(self): + """Action to retrieve both attendance and leave lines""" + self._retrieve_attendance() + self._retrieve_leave() + # this method can be called by cron, ensure that properly recompute + # default comp hours + self._compute_default_compensatory_hour() + self.require_regeneration = False + + def action_attendance_sheet_done(self): + """Method to validate this sheet and generate leave allocation + if necessary + """ + self.generate_leave_allocation() + self.generate_leave() + res = super().action_attendance_sheet_done() + return res + + def action_attendance_sheet_draft(self): + self.clear_leaves_and_allocation() + return super().action_attendance_sheet_draft() + + def clear_leaves_and_allocation(self): + for record in self: + if record.leave_allocation_id: + record.leave_allocation_id.action_refuse() + record.leave_allocation_id.action_draft() + if record.leave_id: + record.leave_id.action_refuse() + record.leave_id.action_draft() + self.remove_leaves() + + def remove_leaves(self): + for record in self: + record.leave_allocation_id = False + record.leave_id = False + + def unlink(self): + self.clear_leaves_and_allocation() + return super().unlink() + + def generate_leave_allocation(self): + HrAllocation = self.env["hr.leave.allocation"] + for sheet in self: + holiday_status_id = ( + sheet.employee_id.company_id.hr_attendance_compensatory_leave_type_id.id + ) + if sheet.compensatory_hour > 0 and not sheet.leave_allocation_id: + sheet.leave_allocation_id = HrAllocation.create( + { + "employee_id": sheet.employee_id.id, + "holiday_status_id": holiday_status_id, + "number_of_days": sheet.compensatory_hour + / sheet.working_hours.hours_per_day, + "holiday_type": "employee", + "state": "validate", + "private_name": _("Compensatory hours: %s") + % sheet.display_name, + "notes": _( + "Allocation created and validated from attendance " + "validation reviews: %s" + ) + % sheet.display_name, + } + ) + return HrAllocation + + def generate_leave(self): + HrLeave = self.env["hr.leave"] + for sheet in self: + holiday_status_id = ( + sheet.employee_id.company_id.hr_attendance_compensatory_leave_type_id.id + ) + if sheet.regularization_compensatory_hour_taken > 0 and not sheet.leave_id: + leave = HrLeave.create( + { + "employee_id": sheet.employee_id.id, + "holiday_status_id": holiday_status_id, + "number_of_days": sheet.regularization_compensatory_hour_taken + / sheet.working_hours.hours_per_day, + "name": _("Compensatory hours regularization generated from %s") + % sheet.display_name, + "request_date_from": sheet.end_date, + "request_date_to": sheet.end_date, + "date_from": sheet.end_date, + "date_to": sheet.end_date, + "request_unit_hours": False, + } + ) + leave.action_validate() + sheet.leave_id = leave + return HrLeave + + @api.onchange( + "leave_hours", + "attendance_hours", + "overtime_due_hours", + "overtime_not_due_hours", + ) + def _compute_default_compensatory_hour(self): + """Re-compute default compensatory hour based on + accepted overtime + """ + for record in self: + diff = ( + record.attendance_hours + + record.leave_hours + + record.overtime_due_hours + - record.hours_to_work + ) + record.compensatory_hour = max(0, diff) + record.regularization_compensatory_hour_taken = abs(min(0, diff)) + + @api.model + def _create_sheet_id(self): + sheets = super()._create_sheet_id() + sheets.action_retrieve_attendance_and_leaves() + return sheets + + def action_to_review(self): + self.write({"state": "draft"}) + + def action_compute_compensatory_hour(self): + self._compute_default_compensatory_hour() diff --git a/hr_attendance_sheet_compensatory/models/hr_employee.py b/hr_attendance_sheet_compensatory/models/hr_employee.py new file mode 100644 index 00000000..d0952d5c --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/hr_employee.py @@ -0,0 +1,111 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from datetime import timedelta + +import pytz +from dateutil.relativedelta import relativedelta + +from odoo import fields, models + + +class HrEmployeeBase(models.AbstractModel): + _inherit = "hr.employee.base" + + hours_current_week = fields.Float(compute="_compute_hours_current_week") + + def _compute_hours(self, start_naive, end_naive): + self.ensure_one() + attendances = self.env["hr.attendance"].search( + [ + ("employee_id", "=", self.id), + "&", + ("check_in", "<=", end_naive), + ("check_out", ">=", start_naive), + "|", + ("is_overtime", "=", False), + "&", + ("is_overtime", "=", True), + ("is_overtime_due", "=", True), + ] + ) + 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 + return hours + + def _compute_hours_current_week(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) + # return last monday + start_tz = ( + now_tz + + timedelta(days=-now_tz.weekday()) + + relativedelta(hour=0, minute=0, second=0, microsecond=0) + ) + start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None) + end_naive = now_tz.astimezone(pytz.utc).replace(tzinfo=None) + hours = self._compute_hours(start_naive, end_naive) + employee.hours_current_week = round(hours, 2) + + def _compute_hours_last_month(self): + # Copied from hr_attendance module to overwrite hr.attendance search + # and refactor with _compute_hours_current_week + 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) + hours = self._compute_hours(start_naive, end_naive) + employee.hours_last_month = round(hours, 2) + employee.hours_last_month_display = "%g" % employee.hours_last_month + + def _compute_hours_today(self): + # Copied from hr_attendance module to overwrite hr.attendance search + 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), + "|", + ("is_overtime", "=", False), + "&", + ("is_overtime", "=", True), + ("is_overtime_due", "=", True), + ] + ) + + 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 diff --git a/hr_attendance_sheet_compensatory/models/hr_leave.py b/hr_attendance_sheet_compensatory/models/hr_leave.py new file mode 100644 index 00000000..30841c5a --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/hr_leave.py @@ -0,0 +1,13 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html +from odoo import fields, models + + +class HrLeave(models.Model): + _inherit = "hr.leave" + + validation_sheet_id = fields.Many2one( + "hr.attendance.sheet", + string="Validation sheet", + ) diff --git a/hr_attendance_sheet_compensatory/models/res_company.py b/hr_attendance_sheet_compensatory/models/res_company.py new file mode 100644 index 00000000..77a5b5da --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/res_company.py @@ -0,0 +1,17 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + hr_attendance_compensatory_leave_type_id = fields.Many2one( + "hr.leave.type", + "Overtime compensatory leave type", + required=True, + default=lambda self: self.env.ref("hr_holidays.holiday_status_comp"), + help="Compensatory leave type used while validate weekly attendance sheet.", + ) diff --git a/hr_attendance_sheet_compensatory/models/res_config.py b/hr_attendance_sheet_compensatory/models/res_config.py new file mode 100644 index 00000000..9afea9c4 --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/res_config.py @@ -0,0 +1,13 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + hr_attendance_compensatory_leave_type_id = fields.Many2one( + related="company_id.hr_attendance_compensatory_leave_type_id", readonly=False + ) diff --git a/hr_attendance_sheet_compensatory/models/resource_calendar.py b/hr_attendance_sheet_compensatory/models/resource_calendar.py new file mode 100644 index 00000000..c5ee1ebd --- /dev/null +++ b/hr_attendance_sheet_compensatory/models/resource_calendar.py @@ -0,0 +1,21 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import fields, models + + +class ResourceCalendar(models.Model): + _inherit = "resource.calendar" + + total_hours = fields.Float( + "Total calendar hours", + compute="_compute_total_hours", + help="Total theoretical hours used to compute compensatory days", + ) + + def _compute_total_hours(self): + for rec in self: + rec.total_hours = sum( + rec.attendance_ids.mapped(lambda att: att.hour_to - att.hour_from) + ) diff --git a/hr_attendance_sheet_compensatory/readme/CONFIGURE.rst b/hr_attendance_sheet_compensatory/readme/CONFIGURE.rst new file mode 100644 index 00000000..74a345cf --- /dev/null +++ b/hr_attendance_sheet_compensatory/readme/CONFIGURE.rst @@ -0,0 +1,8 @@ +* Ensure employee weeks are properly set +* Set the leave type to use by generating compensatory + hours from attendance review (to be done in hr attendance configuration) +* once all leaves and attendances has been recorded you can generate leave reviews + by setting up a cron job running every monday morning to generate the previous week + with the following code on `hr.attendance.sheet` model:: + + model.generate_reviews() diff --git a/hr_attendance_sheet_compensatory/readme/CONTRIBUTORS.rst b/hr_attendance_sheet_compensatory/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..57db78c1 --- /dev/null +++ b/hr_attendance_sheet_compensatory/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Pierre Verkest +* Maxime Franco diff --git a/hr_attendance_sheet_compensatory/readme/DESCRIPTION.rst b/hr_attendance_sheet_compensatory/readme/DESCRIPTION.rst new file mode 100644 index 00000000..007b9ede --- /dev/null +++ b/hr_attendance_sheet_compensatory/readme/DESCRIPTION.rst @@ -0,0 +1,31 @@ +This is based on `hr_attendance_sheet` which add a validation mechanism +to review employee attendance with an auto-lunch calculation. +This allows to generate compensatory hours (allocated leaves) that can +be used later as day off or to regulate credits leaves as +this module is compatible with `hr_holidays_credit` module. + +This is based on the `hr_attendance_overtime` module which +mark rows as "overtime" those rows are not due by default +as it could came from possible mist check-out. So manager can +decide to add or not those overtime attendance lines or not and +compute or adjust compensatory/leaves hours to generate. + +This module is **incompatible** with hr_attendance_validation +because it implements the same feature and especially generates +errors during hr.attendance validation + +..note:: + + If you are allowing flexible hours - check-in/check-out range + are bigger than average hours per day - So you can generate + compensatory hours from lines that are not marked as overtime. + +Once review is validated attendance lines are locked on that period. + +At the end managers can check holidays allocation per year and +by employee to make sure allowed employee compensatory hours are +not over. + +Employees can: +- access to validated sheets to review hours taken account +- see current week hours on check-in view diff --git a/hr_attendance_sheet_compensatory/readme/USAGE.rst b/hr_attendance_sheet_compensatory/readme/USAGE.rst new file mode 100644 index 00000000..b3f57efb --- /dev/null +++ b/hr_attendance_sheet_compensatory/readme/USAGE.rst @@ -0,0 +1,5 @@ +* Once review has been generate by ir cron manager are able to + open each one on Attenances > Manager > Attendance validation +* On each form decide if recorded overtime are due or not +* change the amount of allocated compensatory hours to generate +* validate the review to generate the allocation diff --git a/hr_attendance_sheet_compensatory/security/hr_attendance_sheet_rule.xml b/hr_attendance_sheet_compensatory/security/hr_attendance_sheet_rule.xml new file mode 100644 index 00000000..021538af --- /dev/null +++ b/hr_attendance_sheet_compensatory/security/hr_attendance_sheet_rule.xml @@ -0,0 +1,31 @@ + + + + + user: read own attendance validation sheet only + + [('employee_id.user_id','=',user.id), ('state', '=', 'done')] + + + + + + + + Manager: access to attendances validation sheets + + [(1,'=',1)] + + + + + + + diff --git a/hr_attendance_sheet_compensatory/static/description/index.html b/hr_attendance_sheet_compensatory/static/description/index.html new file mode 100644 index 00000000..058efd9f --- /dev/null +++ b/hr_attendance_sheet_compensatory/static/description/index.html @@ -0,0 +1,474 @@ + + + + + + +HR Attendance Sheet Compensatory + + + +
+

HR Attendance Sheet Compensatory

+ + +

Beta License: AGPL-3 OCA/hr-attendance Translate me on Weblate Try me on Runboat

+

This is based on hr_attendance_sheet which add a validation mechanism +to review employee attendance with an auto-lunch calculation. +This allows to generate compensatory hours (allocated leaves) that can +be used later as day off or to regulate credits leaves as +this module is compatible with hr_holidays_credit module.

+

This is based on the hr_attendance_overtime module which +mark rows as “overtime” those rows are not due by default +as it could came from possible mist check-out. So manager can +decide to add or not those overtime attendance lines or not and +compute or adjust compensatory/leaves hours to generate.

+

This module is incompatible with hr_attendance_validation +because it implements the same feature and especially generates +errors during hr.attendance validation

+

..note:

+
+If you are allowing flexible hours - check-in/check-out range
+are bigger than average hours per day - So you can generate
+compensatory hours from lines that are not marked as overtime.
+
+

Once review is validated attendance lines are locked on that period.

+

At the end managers can check holidays allocation per year and +by employee to make sure allowed employee compensatory hours are +not over.

+

Employees can: +- access to validated sheets to review hours taken account +- see current week hours on check-in view

+

Table of contents

+ +
+

Configuration

+
    +
  • Ensure employee weeks are properly set

    +
  • +
  • Set the leave type to use by generating compensatory +hours from attendance review (to be done in hr attendance configuration)

    +
  • +
  • once all leaves and attendances has been recorded you can generate leave reviews +by setting up a cron job running every monday morning to generate the previous week +with the following code on hr.attendance.sheet model:

    +
    +model.generate_reviews()
    +
    +
  • +
+
+
+

Usage

+
    +
  • Once review has been generate by ir cron manager are able to +open each one on Attenances > Manager > Attendance validation
  • +
  • On each form decide if recorded overtime are due or not
  • +
  • change the amount of allocated compensatory hours to generate
  • +
  • validate the review to generate the allocation
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Odoo S.A.
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/hr-attendance project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/hr_attendance_sheet_compensatory/static/src/js/hr_attendance_sheet.js b/hr_attendance_sheet_compensatory/static/src/js/hr_attendance_sheet.js new file mode 100644 index 00000000..b3c25b73 --- /dev/null +++ b/hr_attendance_sheet_compensatory/static/src/js/hr_attendance_sheet.js @@ -0,0 +1,31 @@ +odoo.define("hr_attendance_sheet_compensatory.my_attendances", function (require) { + "use strict"; + + var MyAttendances = require("hr_attendance.my_attendances"); + var field_utils = require("web.field_utils"); + + MyAttendances.include({ + willStart: function () { + var self = this; + self.hours_current_week = 0; + // PV: we could avoid extra request if hr_attendance allowed to defined which fields + // to requests + var def = this._rpc({ + model: "hr.employee", + method: "search_read", + args: [ + [["user_id", "=", this.getSession().uid]], + ["hours_current_week"], + ], + }).then(function (res) { + if (res.length) { + self.hours_current_week = field_utils.format.float_time( + res[0].hours_current_week + ); + } + }); + + return Promise.all([def, this._super.apply(this, arguments)]); + }, + }); +}); diff --git a/hr_attendance_sheet_compensatory/static/src/xml/attendance.xml b/hr_attendance_sheet_compensatory/static/src/xml/attendance.xml new file mode 100644 index 00000000..9274be5b --- /dev/null +++ b/hr_attendance_sheet_compensatory/static/src/xml/attendance.xml @@ -0,0 +1,15 @@ + + + diff --git a/hr_attendance_sheet_compensatory/tests/__init__.py b/hr_attendance_sheet_compensatory/tests/__init__.py new file mode 100644 index 00000000..f9653727 --- /dev/null +++ b/hr_attendance_sheet_compensatory/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_attendance_sheet diff --git a/hr_attendance_sheet_compensatory/tests/test_hr_attendance_sheet.py b/hr_attendance_sheet_compensatory/tests/test_hr_attendance_sheet.py new file mode 100644 index 00000000..755ee702 --- /dev/null +++ b/hr_attendance_sheet_compensatory/tests/test_hr_attendance_sheet.py @@ -0,0 +1,546 @@ +# Copyright 2021 Pierre Verkest +# Copyright 2023 ACSONE SA/NV +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from datetime import date + +from freezegun import freeze_time + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import Form +from odoo.tests.common import TransactionCase + + +class TestHrAttendanceSheet(TransactionCase): + def setup_employee(self): + employee_group = self.env.ref("hr_attendance.group_hr_attendance_user") + self.user_employee = self.env["res.users"].create( + { + "name": "Test User Employee 1", + "login": "test 1", + "email": "test1@test.com", + "groups_id": [(6, 0, [employee_group.id])], + "tz": "UTC", + } + ) + self.employee = self.env["hr.employee"].create( + { + "name": "Employee 1", + "tz": "UTC", + "user_id": self.user_employee.id, + "use_attendance_sheets": True, + "hours_to_work": 40.0, + } + ) + self.employee2 = self.env["hr.employee"].create( + { + "name": "Employee 2 without user", + "tz": "UTC", + "use_attendance_sheets": True, + "hours_to_work": 40.0, + } + ) + self.user_admin = self.env["res.users"].browse(1) + self.employee_admin = self.env["hr.employee"].create( + { + "name": "Employee Admin", + "tz": "UTC", + "user_id": self.user_admin.id, + "use_attendance_sheets": False, + } + ) + + def setup_employee_allocation(self): + self.env["hr.leave.allocation"].create( + { + "employee_id": self.employee2.id, + "holiday_status_id": self.leave_comp.id, + "number_of_days": 10, + "holiday_type": "employee", + "state": "validate", + "name": "10 days - Compensatory hours", + } + ) + + def setup_employee_holidays(self): + self.env["hr.leave.allocation"].create( + { + "employee_id": self.employee.id, + "holiday_status_id": self.leave_cl.id, + "number_of_days": 30, + "holiday_type": "employee", + "state": "validate", + } + ) + self.empl_leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "holiday_status_id": self.leave_cl.id, + # overlap two weeks + "request_date_from": "2021-12-01", + "request_date_to": "2021-12-08", + "number_of_days": 6, + } + ) + self.empl_leave.action_validate() + + self.env["hr.leave.allocation"].create( + { + "employee_id": self.employee.id, + "holiday_status_id": self.leave_comp.id, + "number_of_days": 1, + "holiday_type": "employee", + "state": "validate", + } + ) + self.empl_leave_comp = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "holiday_status_id": self.leave_comp.id, + "request_date_from": "2021-12-10", + "request_hour_from": "8", + "request_hour_to": "12", + "request_unit_hours": True, + "number_of_days": 0.5, + } + ) + self.empl_leave_comp.action_validate() + + def setup_employee_attendances(self): + self.env["hr.attendance"].create( + [ + { # testing record before not considered + "employee_id": self.employee.id, + "check_in": "2021-12-05 07:30:00", + "check_out": "2021-12-05 08:00:00", + }, + { # testing other employee + "employee_id": self.employee2.id, + "check_in": "2021-12-06 08:00:00", + "check_out": "2021-12-06 12:00:00", + }, + { + "employee_id": self.employee.id, + "check_in": "2021-12-09 07:30:00", + "check_out": "2021-12-09 08:00:00", + "is_overtime": True, + }, + { + "employee_id": self.employee.id, + "check_in": "2021-12-09 08:00:00", + "check_out": "2021-12-09 12:00:00", + "is_overtime": False, + }, + { + "employee_id": self.employee.id, + "check_in": "2021-12-09 13:00:00", + "check_out": "2021-12-09 17:00:00", + "is_overtime": False, + }, + { + "employee_id": self.employee.id, + "check_in": "2021-12-10 14:00:00", + "check_out": "2021-12-10 17:00:00", + "is_overtime": False, + }, + { + "employee_id": self.employee.id, + "check_in": "2021-12-10 17:00:00", + "check_out": "2021-12-10 18:30:00", + "is_overtime": True, + "is_overtime_due": True, + }, + { # testing record after not considered + "employee_id": self.employee.id, + "check_in": "2021-12-13 07:30:00", + "check_out": "2021-12-13 08:00:00", + }, + ] + ) + + def setUp(self): + super().setUp() + self.HrAttendance = self.env["hr.attendance"] + self.HrAttendanceSheet = self.env["hr.attendance.sheet"] + self.AttendanceSheetConfig = self.env["hr.attendance.sheet.config"] + self.leave_cl = self.env.ref("hr_holidays.holiday_status_cl") + self.leave_comp = self.env.ref("hr_holidays.holiday_status_comp") + self.company = self.env.ref("base.main_company") + self.company.write( + { + "use_attendance_sheets": True, + } + ) + self.attendance_config = self.AttendanceSheetConfig.create( + { + "start_date": "2021-12-01", + "end_date": "2021-12-31", + "period": "WEEKLY", + "auto_lunch": True, + "company_id": self.company.id, + } + ) + self.setup_employee() + self.setup_employee_allocation() + self.setup_employee_holidays() + self.setup_employee_attendances() + + def test_name_get_missing_employee(self): + with freeze_time("2021-12-12 20:45", tz_offset=0): + new_element = self.HrAttendanceSheet.new({}) + self.assertEqual(new_element.name_get()[0][1], "False") + + def test_require_regeneration(self): + validation_sheet = self.HrAttendanceSheet.create( + { + "employee_id": self.employee.id, + "start_date": "2021-12-13", + "end_date": "2021-12-19", + } + ) + validation_sheet.action_retrieve_attendance_and_leaves() + with Form(validation_sheet) as form: + self.assertFalse(form.require_regeneration) + form.employee_id = self.employee2 + self.assertTrue(form.require_regeneration) + form.save() + self.assertTrue(validation_sheet) + validation_sheet.action_retrieve_attendance_and_leaves() + self.assertFalse(validation_sheet.require_regeneration) + + def test_name_get_multi(self): + weeks = self.validate_week() + weeks += self.HrAttendanceSheet.create( + { + "employee_id": self.employee.id, + "start_date": "2021-12-13", + "end_date": "2021-12-19", + } + ) + res = weeks.name_get() + self.assertEqual(len(res), 2) + self.assertEqual(res[0][1], "Employee 1 (12/06/21 - 12/12/21)") + self.assertEqual(res[1][1], "Employee 1 (12/13/21 - 12/19/21)") + + def test_default_from_date(self): + with freeze_time("2021-12-12 20:45", tz_offset=0): + new_element = self.HrAttendanceSheet.new({}) + self.assertEqual(new_element.start_date, date(2021, 11, 29)) + self.assertEqual(new_element.end_date, date(2021, 12, 5)) + + with freeze_time("2021-12-13 06:45", tz_offset=0): + new_element = self.HrAttendanceSheet.new({}) + self.assertEqual(new_element.start_date, date(2021, 12, 6)) + self.assertEqual(new_element.end_date, date(2021, 12, 12)) + + with freeze_time("2021-12-14 06:45", tz_offset=0): + new_element = self.HrAttendanceSheet.new({}) + self.assertEqual(new_element.start_date, date(2021, 12, 6)) + self.assertEqual(new_element.end_date, date(2021, 12, 12)) + + def test_action_retrieve_attendance_and_leaves(self): + validation = self.HrAttendanceSheet.new( + { + "employee_id": self.employee.id, + } + ) + validation.action_retrieve_attendance_and_leaves() + self.assertFalse(validation.leave_ids) + self.assertFalse(validation.attendance_ids) + validation.start_date = "2021-12-06" + validation.end_date = "2021-12-12" + validation.action_retrieve_attendance_and_leaves() + self.assertEqual(len(validation.leave_ids), 2) + self.assertEqual(validation.leave_hours, 28) + self.assertEqual(len(validation.attendance_ids), 5) + + def test_action_retrieve_leaves_outer_validation_date(self): + validation = self.HrAttendanceSheet.new( + { + "employee_id": self.employee.id, + } + ) + validation.start_date = "2021-12-07" + validation.end_date = "2021-12-08" + validation.action_retrieve_attendance_and_leaves() + self.assertEqual(len(validation.leave_ids), 1) + self.assertEqual(validation.leave_hours, 16) + + def test_computed_fields_base(self): + # resource.resource_calendar_std is 40 hours/week + # from 8 to 12 and 13 to 17 + validation = self.HrAttendanceSheet.new( + { + "employee_id": self.employee.id, + "start_date": "2021-12-06", + "end_date": "2021-12-12", + } + ) + validation.action_retrieve_attendance_and_leaves() + self.assertEqual(validation.theoretical_hours, 40) + self.assertEqual(validation.attendance_hours, 11) + self.assertEqual(validation.overtime_due_hours, 1.5) + self.assertEqual(validation.attendance_total_hours, 12.5) + self.assertEqual(validation.overtime_not_due_hours, 0.5) + self.assertEqual(validation.leave_hours, 3 * 8 + 0.5 * 8) + self.assertEqual(validation.compensatory_hour, 0.5) + self.assertEqual(validation.regularization_compensatory_hour_taken, 0) + + def test_generate_compensatory(self): + leaves_before = self.leave_comp.with_context( + employee_id=self.employee.id + ).remaining_leaves + validation = self.validate_week() + self.assertEqual(validation.state, "done") + self.assertEqual( + validation.leave_allocation_id.holiday_status_id.id, self.leave_comp.id + ) + self.leave_comp.refresh() + self.assertEqual( + self.leave_comp.with_context(employee_id=self.employee.id).remaining_leaves, + leaves_before + 0.5, + ) + self.assertTrue( + validation.leave_allocation_id.name, + ) + self.assertEqual( + validation.leave_allocation_id.notes, + "Allocation created and validated from attendance " + "validation reviews: Employee 1 (12/06/21 - 12/12/21)", + ) + + def test_generate_leaves(self): + leaves_before = self.leave_comp.with_context( + employee_id=self.employee2.id + ).remaining_leaves + + validation = self.HrAttendanceSheet.create( + { + "employee_id": self.employee2.id, + "start_date": "2021-12-06", + "end_date": "2021-12-12", + } + ) + validation.action_retrieve_attendance_and_leaves() + validation.action_attendance_sheet_confirm() + validation.action_attendance_sheet_done() + self.assertEqual(validation.state, "done") + self.assertEqual(validation.leave_id.holiday_status_id.id, self.leave_comp.id) + self.leave_comp.refresh() + self.assertEqual(validation.regularization_compensatory_hour_taken, 36) + self.assertEqual( + self.leave_comp.with_context( + employee_id=self.employee2.id + ).remaining_leaves, + leaves_before - validation.regularization_compensatory_hour_taken, + ) + self.assertEqual( + validation.leave_id.name, + "Compensatory hours regularization generated from " + "Employee 2 without user (12/06/21 - 12/12/21)", + ) + + def validate_week(self): + validation = self.HrAttendanceSheet.create( + { + "employee_id": self.employee.id, + "start_date": "2021-12-06", + "end_date": "2021-12-12", + } + ) + validation.action_attendance_sheet_confirm() + validation.action_retrieve_attendance_and_leaves() + validation.action_attendance_sheet_done() + return validation + + def test_could_not_create_employee_attendance_on_validated_week(self): + self.validate_week() + with self.assertRaisesRegex( + ValidationError, + "Cannot create new attendance for employee Employee 1. " + "Attendance for the day of the check in 2021-12-12 has already been " + "reviewed and validated.", + ): + self.env["hr.attendance"].create( + [ + { + "employee_id": self.employee.id, + "check_in": "2021-12-12 08:00:00", + "check_out": "2021-12-12 12:00:00", + }, + ] + ) + + def test_create_employee_attendance_on_validated_week(self): + self.validate_week() + self.env["hr.attendance"].create( + [ + { # testing record before if fine + "employee_id": self.employee.id, + "check_in": "2021-12-05 20:30:00", + "check_out": "2021-12-05 21:00:00", + }, + { # testing other employee is ok + "employee_id": self.employee2.id, + "check_in": "2021-12-06 20:00:00", + "check_out": "2021-12-06 21:00:00", + }, + ] + ) + + def test_unlink_attendance(self): + att = self.env["hr.attendance"].search( + [("employee_id", "=", self.employee.id), ("check_in", ">", "2021-12-12")] + ) + att.ensure_one() + self.assertTrue(att.unlink()) + self.assertEqual( + self.env["hr.attendance"].search_count( + [ + ("employee_id", "=", self.employee.id), + ("check_in", ">", "2021-12-12"), + ] + ), + 0, + ) + + def test_unlink_attendance_forbiden(self): + self.validate_week() + attendances = self.env["hr.attendance"].search( + [("employee_id", "=", self.employee.id)] + ) + with self.assertRaisesRegex( + ValidationError, + r"Can not remove this attendance \(Employee 1, .*\) " + "which has been already reviewed and validated.", + ): + attendances.unlink() + + def test_write_attendance(self): + att = self.env["hr.attendance"].search( + [("employee_id", "=", self.employee.id), ("check_in", ">", "2021-12-12")] + ) + att.ensure_one() + att.write({"is_overtime_due": True}) + + def test_write_attendance_forbiden(self): + self.validate_week() + attendances = self.env["hr.attendance"].search( + [("employee_id", "=", self.employee.id)] + ) + with self.assertRaisesRegex( + ValidationError, + r"Can not change this attendance \(Employee 1, .*\) " + "which has been already reviewed and validated.", + ): + attendances.write({"is_overtime_due": True}) + + def test_write_attendance_forbiden_after_change(self): + self.validate_week() + attendances = self.env["hr.attendance"].search( + [("employee_id", "=", self.employee.id), ("check_in", ">", "2021-12-12")] + ) + with self.assertRaisesRegex( + ValidationError, + r"Can not change this attendance \(Employee 1, 2021-12-12\) " + "which would be moved to a validated day.", + ): + attendances.write( + {"check_in": "2021-12-12 22:00", "check_out": "2021-12-12 23:00"} + ) + + def test_generate_reviews(self): + reviews = self.HrAttendanceSheet._create_sheet_id() + self.assertEqual( + len(reviews), + self.env["hr.employee"].search_count( + [("use_attendance_sheets", "=", True)] + ), + ) + + def test_avoid_duplicated_allocation(self): + # in case allocation is generated + # we come back to draft mode "to review", removing the + # previously created allocatoin is left to the user + # once re-validate avoid duplication in allocation + count_before = self.env["hr.leave.allocation"].search_count([]) + attenance_review_week = self.validate_week() + self.assertEqual( + self.env["hr.leave.allocation"].search_count([]), count_before + 1 + ) + attenance_review_week.action_to_review() + self.assertEqual( + self.env["hr.leave.allocation"].search_count([]), count_before + 1 + ) + self.assertEqual(attenance_review_week.state, "draft") + self.assertTrue(attenance_review_week.leave_allocation_id) + attenance_review_week.action_attendance_sheet_confirm() + attenance_review_week.action_attendance_sheet_done() + self.assertEqual( + self.env["hr.leave.allocation"].search_count([]), count_before + 1 + ) + self.assertEqual(attenance_review_week.state, "done") + self.assertTrue(attenance_review_week.leave_allocation_id) + + def test_employee_check_in_out(self): + # in check-in/check-out processus odoo make sure + # the week is not already validated which require + # access to + # `hr.attendance.sheet`'s records + employee = self.employee.with_user(self.user_employee) + with freeze_time("2021-12-30 09:01", tz_offset=0): + employee._attendance_action_change() + with freeze_time("2021-12-30 11:01", tz_offset=0): + employee._attendance_action_change() + + def test_user_can_read_validated_sheet_only(self): + validation = self.HrAttendanceSheet.create( + { + "employee_id": self.employee.id, + "start_date": "2021-12-06", + "end_date": "2021-12-12", + } + ) + validation.action_retrieve_attendance_and_leaves() + + HrAttendanceSheetEmployee = self.HrAttendanceSheet.with_user(self.user_employee) + self.assertEqual(HrAttendanceSheetEmployee.search_count([]), 0) + with self.assertRaisesRegex(AccessError, "Due to security restrictions.*"): + validation.with_user(self.user_employee).read(["start_date"]) + validation.action_attendance_sheet_confirm() + validation.action_attendance_sheet_done() + self.assertEqual(HrAttendanceSheetEmployee.search_count([]), 1) + validation.with_user(self.user_employee).read(["start_date"]) + + def test_user_cant_read_others_sheets(self): + # employee = self.employee.with_user(self.user_employee) + validation = self.HrAttendanceSheet.create( + { + "employee_id": self.employee2.id, + "start_date": "2021-12-06", + "end_date": "2021-12-12", + } + ) + validation.action_retrieve_attendance_and_leaves() + + HrAttendanceSheetEmployee = self.HrAttendanceSheet.with_user(self.user_employee) + self.assertEqual(HrAttendanceSheetEmployee.search_count([]), 0) + with self.assertRaisesRegex(AccessError, "Due to security restrictions.*"): + validation.with_user(self.user_employee).read(["start_date"]) + validation.action_attendance_sheet_confirm() + validation.action_attendance_sheet_done() + self.assertEqual(HrAttendanceSheetEmployee.search_count([]), 0) + with self.assertRaisesRegex(AccessError, "Due to security restrictions.*"): + validation.with_user(self.user_employee).read(["start_date"]) + + def test_employee_works_hours(self): + with freeze_time("2021-12-10 19:45", tz_offset=0): + self.assertEqual(self.employee.hours_current_week, 12.5) + self.assertEqual(self.employee.hours_last_month, 0) + self.assertEqual(self.employee.hours_today, 4.5) + + def test_employee_works_hours_month_before(self): + with freeze_time("2022-01-10 19:45", tz_offset=0): + self.assertEqual(self.employee.hours_current_week, 0) + self.assertEqual(self.employee.hours_last_month, 13.5) + self.assertEqual(self.employee.hours_today, 0) diff --git a/hr_attendance_sheet_compensatory/views/assets.xml b/hr_attendance_sheet_compensatory/views/assets.xml new file mode 100644 index 00000000..09360906 --- /dev/null +++ b/hr_attendance_sheet_compensatory/views/assets.xml @@ -0,0 +1,18 @@ + + + +