diff --git a/hr_holidays_public_project_timesheet_holidays/README.rst b/hr_holidays_public_project_timesheet_holidays/README.rst new file mode 100644 index 00000000..e69de29b diff --git a/hr_holidays_public_project_timesheet_holidays/__init__.py b/hr_holidays_public_project_timesheet_holidays/__init__.py new file mode 100644 index 00000000..c0d9f3d1 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from .hooks import post_init_hook diff --git a/hr_holidays_public_project_timesheet_holidays/__manifest__.py b/hr_holidays_public_project_timesheet_holidays/__manifest__.py new file mode 100644 index 00000000..677e9c2f --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2015 2011,2013 Michael Telahun Makonnen +# Copyright 2020 InitOS Gmbh +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "HR Holidays Public Project Timesheet", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "category": "Human Resources", + "author": "Camptocamp SA, Odoo Community Association (OCA),", + "summary": "Manage Timesheets forPublic Holidays", + "website": "https://github.com/OCA/hr-holidays", + "depends": [ + "hr_holidays_public", + "project_timesheet_holidays", + ], + "data": [], + "post_init_hook": "post_init_hook", + "installable": True, + "auto_install": True, +} diff --git a/hr_holidays_public_project_timesheet_holidays/hooks.py b/hr_holidays_public_project_timesheet_holidays/hooks.py new file mode 100644 index 00000000..d08a2b3b --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/hooks.py @@ -0,0 +1,23 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields + + +def post_init_hook(env): + """Backfill future public-holiday timesheets for existing employees.""" + holiday_lines = env["calendar.public.holiday.line"].search( + [("date", ">=", fields.Date.today())] + ) + if not holiday_lines: + return + + employees = env["hr.employee"].search( + [ + ("active", "=", True), + ("address_id", "!=", False), + ] + ) + if not employees: + return + + holiday_lines._generate_public_holiday_timesheets(employees) diff --git a/hr_holidays_public_project_timesheet_holidays/models/__init__.py b/hr_holidays_public_project_timesheet_holidays/models/__init__.py new file mode 100644 index 00000000..bb74d226 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/models/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import account_analytic_line +from . import calendar_public_holiday_line +from . import hr_employee +from . import hr_leave diff --git a/hr_holidays_public_project_timesheet_holidays/models/account_analytic_line.py b/hr_holidays_public_project_timesheet_holidays/models/account_analytic_line.py new file mode 100644 index 00000000..92fe7df4 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/models/account_analytic_line.py @@ -0,0 +1,25 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + public_holiday_line_id = fields.Many2one( + "calendar.public.holiday.line", + string="Public Holiday", + ondelete="set null", + index="btree_not_null", + ) + + @api.ondelete(at_uninstall=False) + def _unlink_except_linked_leave(self): + if any(line.public_holiday_line_id for line in self): + raise UserError( + self.env._( + "You cannot delete timesheets that are linked to public holidays." + ) + ) + return super()._unlink_except_linked_leave() diff --git a/hr_holidays_public_project_timesheet_holidays/models/calendar_public_holiday_line.py b/hr_holidays_public_project_timesheet_holidays/models/calendar_public_holiday_line.py new file mode 100644 index 00000000..cf159af0 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/models/calendar_public_holiday_line.py @@ -0,0 +1,267 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict +from datetime import datetime, time + +import pytz + +from odoo import api, fields, models + + +class CalendarPublicHolidayLine(models.Model): + _inherit = "calendar.public.holiday.line" + + def _get_holidays_lines_by_partner(self, partner_ids, start_dt, end_dt): + holiday_api = self.env["calendar.public.holiday"] + return { + partner_id: holiday_api.get_holidays_list( + start_dt=start_dt, + end_dt=end_dt, + partner_id=partner_id, + ) + for partner_id in partner_ids + } + + def _prepare_public_holiday_timesheet_vals(self, employee, work_hours_count): + project = employee.company_id.internal_project_id + task = employee.company_id.leave_timesheet_task_id + return { + "name": self.env._("Time Off"), + "project_id": project.id, + "task_id": task.id, + "account_id": project.account_id.id, + "unit_amount": work_hours_count, + "user_id": employee.user_id.id, + "date": self.date, + "employee_id": employee.id, + "company_id": employee.company_id.id, + "public_holiday_line_id": self.id, + } + + def _get_applicable_employees(self, employees, holidays_by_partner=None): + self.ensure_one() + if not employees: + return self.env["hr.employee"] + + employees = employees.filtered("address_id") + if not employees: + return employees + + employees_by_partner = defaultdict(lambda: self.env["hr.employee"]) + for employee in employees: + employees_by_partner[employee.address_id.id] |= employee + + holidays_by_partner = holidays_by_partner or {} + missing_partners = [ + partner_id + for partner_id in employees_by_partner + if partner_id not in holidays_by_partner + ] + if missing_partners: + holidays_by_partner.update( + self._get_holidays_lines_by_partner( + missing_partners, + start_dt=self.date, + end_dt=self.date, + ) + ) + + applicable_employees = self.env["hr.employee"] + for partner_id, partner_employees in employees_by_partner.items(): + partner_lines = holidays_by_partner[partner_id] + if self in partner_lines: + applicable_employees |= partner_employees + return applicable_employees + + def _get_work_hours_per_employee(self, employees): + self.ensure_one() + work_hours_per_employee = {} + employees_by_calendar = defaultdict(lambda: self.env["hr.employee"]) + for employee in employees: + if not employee.resource_calendar_id: + continue + employees_by_calendar[employee.resource_calendar_id] |= employee + + for calendar, calendar_employees in employees_by_calendar.items(): + if calendar.flexible_hours: + for employee in calendar_employees: + work_hours_per_employee[employee.id] = calendar.hours_per_day + continue + + calendar_tz = pytz.timezone(calendar.tz) + day_start = calendar_tz.localize(datetime.combine(self.date, time.min)) + day_end = calendar_tz.localize(datetime.combine(self.date, time.max)) + intervals = calendar._attendance_intervals_batch( + day_start.astimezone(pytz.utc), + day_end.astimezone(pytz.utc), + resources=calendar_employees.resource_id, + tz=calendar_tz, + ) + for employee in calendar_employees: + employee_intervals = intervals.get(employee.resource_id.id, []) + work_hours_per_employee[employee.id] = sum( + (date_to - date_from).total_seconds() / 3600 + for date_from, date_to, _dummy in employee_intervals + ) + + return work_hours_per_employee + + def _get_validated_leave_days_per_employee(self, employees): + leave_days_per_employee = defaultdict(list) + if not employees: + return leave_days_per_employee + + leaves_read_group = self.env["hr.leave"]._read_group( + [ + ("employee_id", "in", employees.ids), + ("state", "=", "validate"), + ("date_from", "<=", self.date), + ("date_to", ">=", self.date), + ], + ["employee_id"], + ["date_from:array_agg", "date_to:array_agg"], + ) + for employee, date_from_list, date_to_list in leaves_read_group: + leave_days_per_employee[employee.id] = [ + (date_from.date(), date_to.date()) + for date_from, date_to in zip( + date_from_list, date_to_list, strict=False + ) + ] + return leave_days_per_employee + + def _get_existing_timesheet_employee_ids(self, employees): + existing_read_group = self.env["account.analytic.line"]._read_group( + [ + ("employee_id", "in", employees.ids), + ("date", "=", self.date), + "|", + ("public_holiday_line_id", "=", self.id), + ("global_leave_id", "!=", False), + ], + ["employee_id"], + ["__count"], + ) + return {employee.id for employee, _count in existing_read_group} + + def _generate_public_holiday_timesheets( + self, employees, skip_validated_leave_check=False + ): + vals_list = [] + today = fields.Date.today() + holidays_by_partner = {} + for holiday_line in self.filtered( + lambda line: line.date and line.date >= today + ): + applicable_employees = holiday_line._get_applicable_employees( + employees, + holidays_by_partner=holidays_by_partner, + ) + applicable_employees = applicable_employees.filtered( + lambda employee: employee.active + and employee.company_id.internal_project_id + and employee.company_id.leave_timesheet_task_id + ) + if not applicable_employees: + continue + + leave_days_per_employee = defaultdict(list) + if not skip_validated_leave_check: + leave_days_per_employee = ( + holiday_line._get_validated_leave_days_per_employee( + applicable_employees + ) + ) + existing_timesheet_employee_ids = ( + holiday_line._get_existing_timesheet_employee_ids(applicable_employees) + ) + work_hours_per_employee = holiday_line._get_work_hours_per_employee( + applicable_employees + ) + + for employee in applicable_employees: + if employee.id in existing_timesheet_employee_ids: + continue + + if not skip_validated_leave_check: + employee_leave_days = leave_days_per_employee.get(employee.id, []) + if any( + date_from <= holiday_line.date <= date_to + for date_from, date_to in employee_leave_days + ): + continue + + work_hours = work_hours_per_employee.get(employee.id, 0) + if not work_hours: + continue + + vals_list.append( + holiday_line._prepare_public_holiday_timesheet_vals( + employee, + work_hours, + ) + ) + + return self.env["account.analytic.line"].sudo().create(vals_list) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + employees = self.env["hr.employee"].search( + [ + ("active", "=", True), + ("address_id", "!=", False), + ] + ) + records._generate_public_holiday_timesheets(employees) + return records + + def write(self, vals): + should_regenerate = False + changed_lines = self.env["calendar.public.holiday.line"] + if "date" in vals: + target_date = fields.Date.to_date(vals["date"]) if vals["date"] else False + changed_lines = self.filtered(lambda line: line.date != target_date) + should_regenerate = bool(changed_lines) + + if should_regenerate: + old_timesheets = ( + self.env["account.analytic.line"] + .sudo() + .search( + [ + ("public_holiday_line_id", "in", changed_lines.ids), + ("date", ">=", fields.Date.today()), + ] + ) + ) + old_timesheets.write({"public_holiday_line_id": False}) + old_timesheets.unlink() + + result = super().write(vals) + + if should_regenerate: + employees = self.env["hr.employee"].search( + [ + ("active", "=", True), + ("address_id", "!=", False), + ] + ) + changed_lines._generate_public_holiday_timesheets(employees) + + return result + + @api.ondelete(at_uninstall=False) + def _unlink_future_public_holiday_timesheets(self): + timesheets = ( + self.env["account.analytic.line"] + .sudo() + .search( + [ + ("public_holiday_line_id", "in", self.ids), + ("date", ">=", fields.Date.today()), + ] + ) + ) + timesheets.write({"public_holiday_line_id": False}) + timesheets.unlink() diff --git a/hr_holidays_public_project_timesheet_holidays/models/hr_employee.py b/hr_holidays_public_project_timesheet_holidays/models/hr_employee.py new file mode 100644 index 00000000..5152bc66 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/models/hr_employee.py @@ -0,0 +1,68 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + @api.model_create_multi + def create(self, vals_list): + employees = super().create(vals_list) + if self.env.context.get("salary_simulation"): + return employees + employees.with_context( + allowed_company_ids=employees.company_id.ids + )._create_future_public_holiday_timesheets(employees) + return employees + + def write(self, vals): + employees_with_changed_address = self.env["hr.employee"] + employees_to_unarchive = self.env["hr.employee"] + if "address_id" in vals: + employees_with_changed_address = self.filtered( + lambda employee: employee.address_id.id != vals.get("address_id") + ) + if vals.get("active"): + employees_to_unarchive = self.filtered(lambda employee: not employee.active) + + result = super().write(vals) + + self_company = self.with_context(allowed_company_ids=self.company_id.ids) + if "active" in vals: + if vals.get("active"): + employees_to_unarchive.with_env( + self_company.env + )._create_future_public_holiday_timesheets(employees_to_unarchive) + else: + self_company._delete_future_public_holiday_timesheets() + elif "address_id" in vals and employees_with_changed_address: + employees_with_changed_address.with_env( + self_company.env + )._delete_future_public_holiday_timesheets() + employees_with_changed_address.with_env( + self_company.env + )._create_future_public_holiday_timesheets(employees_with_changed_address) + + return result + + def _delete_future_public_holiday_timesheets(self): + timesheets = ( + self.env["account.analytic.line"] + .sudo() + .search( + [ + ("employee_id", "in", self.ids), + ("public_holiday_line_id", "!=", False), + ("date", ">=", fields.Date.today()), + ] + ) + ) + timesheets.write({"public_holiday_line_id": False}) + timesheets.unlink() + + def _create_future_public_holiday_timesheets(self, employees): + holiday_lines = self.env["calendar.public.holiday.line"].search( + [("date", ">=", fields.Date.today())] + ) + holiday_lines._generate_public_holiday_timesheets(employees) diff --git a/hr_holidays_public_project_timesheet_holidays/models/hr_leave.py b/hr_holidays_public_project_timesheet_holidays/models/hr_leave.py new file mode 100644 index 00000000..eb34f882 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/models/hr_leave.py @@ -0,0 +1,34 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class HrLeave(models.Model): + _inherit = "hr.leave" + + def _check_missing_public_holiday_timesheets(self): + if not self: + return + + min_date = min(self.mapped("date_from")).date() + max_date = max(self.mapped("date_to")).date() + holiday_lines = self.env["calendar.public.holiday.line"].search( + [ + ("date", ">=", min_date), + ("date", "<=", max_date), + ] + ) + holiday_lines._generate_public_holiday_timesheets( + self.employee_id, + skip_validated_leave_check=True, + ) + + def action_refuse(self): + result = super().action_refuse() + self._check_missing_public_holiday_timesheets() + return result + + def _action_user_cancel(self, reason=None): + result = super()._action_user_cancel(reason) + self._check_missing_public_holiday_timesheets() + return result diff --git a/hr_holidays_public_project_timesheet_holidays/pyproject.toml b/hr_holidays_public_project_timesheet_holidays/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_holidays_public_project_timesheet_holidays/readme/CONFIGURE.md b/hr_holidays_public_project_timesheet_holidays/readme/CONFIGURE.md new file mode 100644 index 00000000..66afbcc6 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/readme/CONFIGURE.md @@ -0,0 +1,2 @@ +Go to Settings -> Timesheets and locate the Holidays section. +In there configure the project and task on which holidays must generate timesheets. \ No newline at end of file diff --git a/hr_holidays_public_project_timesheet_holidays/readme/CONTRIBUTORS.md b/hr_holidays_public_project_timesheet_holidays/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..f0c90755 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://www.camptocamp.com): + - Alexandre Fayolle \<\> diff --git a/hr_holidays_public_project_timesheet_holidays/readme/DESCRIPTION.md b/hr_holidays_public_project_timesheet_holidays/readme/DESCRIPTION.md new file mode 100644 index 00000000..a4571967 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +When the module `project_timesheet_holidays` is installed, Odoo will generate timesheet +entries for leaves, and also for public holidays (if they are managed in the standard Odoo way). +This module allows to get the same behavior for public holidays managed with `hr_holidays_public`. diff --git a/hr_holidays_public_project_timesheet_holidays/readme/USAGE.md b/hr_holidays_public_project_timesheet_holidays/readme/USAGE.md new file mode 100644 index 00000000..7dc21553 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/readme/USAGE.md @@ -0,0 +1,8 @@ +When a new employee is created or their work location is updated, the matching public +holiday calendar is used to create timesheets. Timesheets are only created in the future. + +When new public holidays are created in the future, timesheets are generated for employees +for which the holidays apply. + +At installation of the module, if there are public holidays defined, timesheets are also +created for holidays in the future. diff --git a/hr_holidays_public_project_timesheet_holidays/static/description/icon.png b/hr_holidays_public_project_timesheet_holidays/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/hr_holidays_public_project_timesheet_holidays/static/description/icon.png differ diff --git a/hr_holidays_public_project_timesheet_holidays/static/description/icon.svg b/hr_holidays_public_project_timesheet_holidays/static/description/icon.svg new file mode 100644 index 00000000..a7a26d09 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/static/description/icon.svg @@ -0,0 +1,79 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/hr_holidays_public_project_timesheet_holidays/tests/__init__.py b/hr_holidays_public_project_timesheet_holidays/tests/__init__.py new file mode 100644 index 00000000..f3237c85 --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_public_holiday_timesheets diff --git a/hr_holidays_public_project_timesheet_holidays/tests/test_public_holiday_timesheets.py b/hr_holidays_public_project_timesheet_holidays/tests/test_public_holiday_timesheets.py new file mode 100644 index 00000000..1449286c --- /dev/null +++ b/hr_holidays_public_project_timesheet_holidays/tests/test_public_holiday_timesheets.py @@ -0,0 +1,716 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date, datetime + +from freezegun import freeze_time + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import common + +from odoo.addons.hr_holidays_public_project_timesheet_holidays.hooks import ( + post_init_hook, +) + + +@freeze_time("2026-06-19") +class TestPublicHolidayTimesheets(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + + project = cls.env["project.project"].create( + { + "name": "Internal Project PH", + "company_id": cls.company.id, + "allow_timesheets": True, + } + ) + task = cls.env["project.task"].create( + { + "name": "Public Holidays", + "project_id": project.id, + "company_id": cls.company.id, + } + ) + cls.company.write( + { + "internal_project_id": project.id, + "leave_timesheet_task_id": task.id, + } + ) + + cls.country_a = cls.env["res.country"].create( + {"name": "Country A", "code": "XA"} + ) + cls.country_b = cls.env["res.country"].create( + {"name": "Country B", "code": "XB"} + ) + cls.state_a = cls.env["res.country.state"].create( + { + "name": "State A", + "code": "XSA", + "country_id": cls.country_a.id, + } + ) + + cls.partner_a = cls.env["res.partner"].create( + { + "name": "Address A", + "country_id": cls.country_a.id, + } + ) + cls.partner_b = cls.env["res.partner"].create( + { + "name": "Address B", + "country_id": cls.country_b.id, + } + ) + cls.partner_a_state = cls.env["res.partner"].create( + { + "name": "Address A State", + "country_id": cls.country_a.id, + "state_id": cls.state_a.id, + } + ) + + cls.part_time_calendar = cls.env["resource.calendar"].create( + { + "name": "Part Time 4h", + "company_id": cls.company.id, + "attendance_ids": [ + ( + 0, + 0, + { + "name": "Monday", + "dayofweek": "0", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ) + ], + } + ) + cls.flex_calendar = cls.env["resource.calendar"].create( + { + "name": "Flex 7h", + "company_id": cls.company.id, + "flexible_hours": True, + "hours_per_day": 7, + "hours_per_week": 35, + "full_time_required_hours": 35, + } + ) + + cls.emp_a = cls.env["hr.employee"].create( + { + "name": "Employee A", + "company_id": cls.company.id, + "resource_calendar_id": cls.company.resource_calendar_id.id, + "address_id": cls.partner_a.id, + } + ) + cls.emp_b = cls.env["hr.employee"].create( + { + "name": "Employee B", + "company_id": cls.company.id, + "resource_calendar_id": cls.company.resource_calendar_id.id, + "address_id": cls.partner_b.id, + } + ) + cls.emp_part = cls.env["hr.employee"].create( + { + "name": "Employee PT", + "company_id": cls.company.id, + "resource_calendar_id": cls.part_time_calendar.id, + "address_id": cls.partner_a.id, + } + ) + cls.emp_flex = cls.env["hr.employee"].create( + { + "name": "Employee Flex", + "company_id": cls.company.id, + "resource_calendar_id": cls.flex_calendar.id, + "address_id": cls.partner_a_state.id, + } + ) + + def _create_public_holiday_line(self, holiday_date, country, state_ids=None): + public_holiday = self.env["calendar.public.holiday"].create( + { + "year": holiday_date.year, + "country_id": country.id if country else False, + } + ) + return self.env["calendar.public.holiday.line"].create( + { + "name": f"Holiday {holiday_date}", + "date": holiday_date, + "public_holiday_id": public_holiday.id, + "state_ids": state_ids and [(6, 0, state_ids.ids)] or False, + } + ) + + def test_create_generates_for_applicable_employees(self): + """Create a holiday line and generate timesheets only for matching addresses.""" + holiday_date = date(2026, 7, 6) + line = self._create_public_holiday_line(holiday_date, self.country_a) + timesheets = self.env["account.analytic.line"].search( + [("public_holiday_line_id", "=", line.id)] + ) + + self.assertIn(self.emp_a, timesheets.employee_id) + self.assertIn(self.emp_part, timesheets.employee_id) + self.assertIn(self.emp_flex, timesheets.employee_id) + self.assertNotIn(self.emp_b, timesheets.employee_id) + + def test_global_holiday_generates_for_all_active_employees(self): + """A global holiday line (no country) must generate timesheets + for all employees. + """ + # Use Monday so the part-time Monday-only employee is included. + holiday_date = date(2026, 7, 27) + line = self._create_public_holiday_line(holiday_date, country=False) + timesheets = self.env["account.analytic.line"].search( + [("public_holiday_line_id", "=", line.id)] + ) + + self.assertIn(self.emp_a, timesheets.employee_id) + self.assertIn(self.emp_b, timesheets.employee_id) + self.assertIn(self.emp_part, timesheets.employee_id) + self.assertIn(self.emp_flex, timesheets.employee_id) + + def test_state_scoped_holiday_only_matches_state(self): + """A state-scoped holiday must match only employees + with that state on address. + """ + holiday_date = date(2026, 7, 23) + line = self._create_public_holiday_line( + holiday_date, + self.country_a, + state_ids=self.state_a, + ) + timesheets = self.env["account.analytic.line"].search( + [("public_holiday_line_id", "=", line.id)] + ) + + self.assertIn(self.emp_flex, timesheets.employee_id) + self.assertNotIn(self.emp_a, timesheets.employee_id) + self.assertNotIn(self.emp_part, timesheets.employee_id) + self.assertNotIn(self.emp_b, timesheets.employee_id) + + def test_past_holiday_does_not_generate_timesheets(self): + """A holiday strictly before today must not generate timesheets.""" + holiday_date = date(2026, 6, 18) + line = self._create_public_holiday_line(holiday_date, self.country_a) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [("public_holiday_line_id", "=", line.id)] + ) + ) + + def test_today_holiday_generates_timesheets(self): + """A holiday on today must generate timesheets (date >= today boundary).""" + holiday_date = fields.Date.today() + line = self._create_public_holiday_line(holiday_date, self.country_a) + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + def test_name_update_does_not_regenerate(self): + """Renaming a holiday line must keep existing generated timesheets unchanged.""" + holiday_date = date(2026, 7, 7) + line = self._create_public_holiday_line(holiday_date, self.country_a) + before_ids = set( + self.env["account.analytic.line"] + .search([("public_holiday_line_id", "=", line.id)]) + .ids + ) + + line.write({"name": "Renamed"}) + + after_ids = set( + self.env["account.analytic.line"] + .search([("public_holiday_line_id", "=", line.id)]) + .ids + ) + self.assertEqual(before_ids, after_ids) + + def test_date_update_regenerates(self): + """Changing the holiday date must delete and recreate + linked future timesheets. + """ + line = self._create_public_holiday_line(date(2026, 7, 8), self.country_a) + old_timesheets = self.env["account.analytic.line"].search( + [("public_holiday_line_id", "=", line.id)] + ) + self.assertTrue(old_timesheets) + + line.write({"date": date(2026, 7, 9)}) + new_timesheets = self.env["account.analytic.line"].search( + [("public_holiday_line_id", "=", line.id)] + ) + + self.assertTrue(new_timesheets) + self.assertTrue(all(ts.date == date(2026, 7, 9) for ts in new_timesheets)) + + def test_date_update_to_past_deletes_and_does_not_regenerate(self): + """Changing a future holiday date to the past must delete + and not recreate timesheets. + """ + line = self._create_public_holiday_line(date(2026, 7, 24), self.country_a) + self.assertTrue( + self.env["account.analytic.line"].search_count( + [("public_holiday_line_id", "=", line.id)] + ) + ) + + line.write({"date": date(2026, 6, 1)}) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [("public_holiday_line_id", "=", line.id)] + ) + ) + + def test_address_change_recomputes_future_timesheets(self): + """Changing employee address must reassign future timesheets + to new locality rules. + """ + line_a = self._create_public_holiday_line(date(2026, 7, 10), self.country_a) + line_b = self._create_public_holiday_line(date(2026, 7, 10), self.country_b) + + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line_a.id), + ] + ) + ) + self.emp_a.write({"address_id": self.partner_b.id}) + + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line_a.id), + ("date", ">=", fields.Date.today()), + ] + ) + ) + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line_b.id), + ] + ) + ) + + def test_validated_leave_skips_and_refuse_restores(self): + """Validated leave skips generation, then refusal restores + missing holiday timesheet. + """ + holiday_date = date(2026, 7, 13) + leave_type = self.env["hr.leave.type"].create( + { + "name": "Bridge Leave Type", + "requires_allocation": False, + } + ) + leave = self.env["hr.leave"].create( + { + "name": "Leave overlap", + "employee_id": self.emp_a.id, + "holiday_status_id": leave_type.id, + "request_date_from": holiday_date, + "request_date_to": holiday_date, + } + ) + leave.action_approve() + + line = self._create_public_holiday_line(holiday_date, self.country_a) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + leave.action_refuse() + + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + def test_validated_leave_skips_and_cancel_restores(self): + """Validated leave skips generation, then cancellation restores + missing timesheet. + """ + holiday_date = date(2026, 7, 28) + leave_type = self.env["hr.leave.type"].create( + { + "name": "Bridge Leave Type Cancel", + "requires_allocation": False, + } + ) + leave = self.env["hr.leave"].create( + { + "name": "Leave overlap cancel", + "employee_id": self.emp_a.id, + "holiday_status_id": leave_type.id, + "request_date_from": holiday_date, + "request_date_to": holiday_date, + } + ) + leave.action_approve() + + line = self._create_public_holiday_line(holiday_date, self.country_a) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + leave._action_user_cancel("Cancelled in test") + + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + def test_fixed_calendar_uses_working_hours(self): + """A fixed calendar employee must get expected working hours + on a full workday. + """ + holiday_date = date(2026, 7, 29) + line = self._create_public_holiday_line(holiday_date, self.country_a) + timesheet = self.env["account.analytic.line"].search( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ], + limit=1, + ) + self.assertEqual(timesheet.unit_amount, 8.0) + + def test_part_time_calendar_uses_working_hours(self): + """A part-time employee must get timesheet hours + matching calendar attendance. + """ + # 2026-07-27 is Monday, matching the employee's 08:00-12:00 schedule. + holiday_date = date(2026, 7, 27) + line = self._create_public_holiday_line(holiday_date, self.country_a) + timesheet = self.env["account.analytic.line"].search( + [ + ("employee_id", "=", self.emp_part.id), + ("public_holiday_line_id", "=", line.id), + ], + limit=1, + ) + self.assertEqual(timesheet.unit_amount, 4.0) + + def test_flexible_calendar_uses_hours_per_day(self): + """Flexible calendars must use hours_per_day as generated timesheet duration.""" + holiday_date = date(2026, 7, 14) + line = self._create_public_holiday_line( + holiday_date, + self.country_a, + state_ids=self.state_a, + ) + timesheet = self.env["account.analytic.line"].search( + [ + ("employee_id", "=", self.emp_flex.id), + ("public_holiday_line_id", "=", line.id), + ], + limit=1, + ) + self.assertEqual(timesheet.unit_amount, self.flex_calendar.hours_per_day) + + def test_non_working_day_creates_no_timesheet(self): + """No timesheet should be generated when the holiday falls + on a non-working day. + """ + # 2026-07-12 is a Sunday. + line = self._create_public_holiday_line(date(2026, 7, 12), self.country_a) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + def test_manual_deletion_is_blocked(self): + """Manual unlink of public-holiday-generated timesheets + must raise a UserError. + """ + line = self._create_public_holiday_line(date(2026, 7, 15), self.country_a) + timesheet = self.env["account.analytic.line"].search( + [("public_holiday_line_id", "=", line.id)], + limit=1, + ) + + with self.assertRaises(UserError): + timesheet.unlink() + + def test_global_leave_timesheet_prevents_duplicate_public_holiday_timesheet(self): + """Existing global-leave timesheet must prevent creating + duplicate holiday timesheet. + """ + holiday_date = date(2026, 7, 16) + self.env["resource.calendar.leaves"].create( + { + "name": "Global Leave", + "calendar_id": self.company.resource_calendar_id.id, + "date_from": datetime.combine(holiday_date, datetime.min.time()), + "date_to": datetime.combine(holiday_date, datetime.max.time()), + } + ) + + line = self._create_public_holiday_line(holiday_date, self.country_a) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + def test_salary_simulation_context_skips_creation(self): + """Employee creation in salary_simulation context + must not generate holiday timesheets. + """ + employee = ( + self.env["hr.employee"] + .with_context(salary_simulation=True) + .create( + { + "name": "No PH Timesheet", + "company_id": self.company.id, + "resource_calendar_id": self.company.resource_calendar_id.id, + "address_id": self.partner_a.id, + } + ) + ) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", employee.id), + ("public_holiday_line_id", "!=", False), + ] + ) + ) + + def test_employee_creation_generates_future_public_holiday_timesheets(self): + """Creating an employee must backfill timesheets + for future applicable holidays. + """ + line_a = self._create_public_holiday_line(date(2026, 7, 30), self.country_a) + line_b = self._create_public_holiday_line(date(2026, 7, 30), self.country_b) + + employee = self.env["hr.employee"].create( + { + "name": "Employee Created Later", + "company_id": self.company.id, + "resource_calendar_id": self.company.resource_calendar_id.id, + "address_id": self.partner_a.id, + } + ) + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", employee.id), + ("public_holiday_line_id", "=", line_a.id), + ] + ) + ) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", employee.id), + ("public_holiday_line_id", "=", line_b.id), + ] + ) + ) + + def test_employee_creation_without_company_config_skips_generation(self): + """Employee creation must not generate timesheets + when company mapping is missing. + """ + self._create_public_holiday_line(date(2026, 7, 31), self.country_a) + self.company.write( + { + "internal_project_id": False, + "leave_timesheet_task_id": False, + } + ) + + employee = self.env["hr.employee"].create( + { + "name": "No Config Employee", + "company_id": self.company.id, + "resource_calendar_id": self.company.resource_calendar_id.id, + "address_id": self.partner_a.id, + } + ) + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", employee.id), + ("public_holiday_line_id", "!=", False), + ] + ) + ) + + def test_archive_then_unarchive_employee(self): + """Archiving deletes future holiday timesheets; unarchiving regenerates them.""" + # emp_part works only on Mondays in its test calendar. + line = self._create_public_holiday_line(date(2026, 7, 27), self.country_a) + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_part.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + self.emp_part.active = False + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_part.id), + ("public_holiday_line_id", "=", line.id), + ("date", ">=", fields.Date.today()), + ] + ) + ) + + self.emp_part.active = True + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_part.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + def test_partner_country_write_does_not_refresh(self): + """Writing on partner country must not trigger recomputation + of employee timesheets. + """ + line = self._create_public_holiday_line(date(2026, 7, 20), self.country_a) + before = self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + self.partner_a.country_id = self.country_b + after = self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + self.assertEqual(before, after) + + def test_unlink_holiday_line_keeps_past_timesheet_and_nullifies_link(self): + """Unlinking a holiday line keeps past timesheets + and clears their foreign key. + """ + line = self._create_public_holiday_line(date(2026, 7, 21), self.country_a) + past_ts = self.env["account.analytic.line"].create( + { + "name": "Past PH", + "project_id": self.company.internal_project_id.id, + "task_id": self.company.leave_timesheet_task_id.id, + "account_id": self.company.internal_project_id.account_id.id, + "unit_amount": 8, + "user_id": self.env.user.id, + "date": date(2026, 6, 1), + "employee_id": self.emp_a.id, + "company_id": self.company.id, + "public_holiday_line_id": line.id, + } + ) + + line.unlink() + past_ts.invalidate_recordset(["public_holiday_line_id"]) + self.assertTrue(past_ts.exists()) + self.assertFalse(past_ts.public_holiday_line_id) + + def test_generated_timesheet_uses_company_project_and_task(self): + """Generated timesheet must use the configured company + internal project and task. + """ + line = self._create_public_holiday_line(date(2026, 8, 3), self.country_a) + timesheet = self.env["account.analytic.line"].search( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ], + limit=1, + ) + + self.assertEqual(timesheet.project_id, self.company.internal_project_id) + self.assertEqual(timesheet.task_id, self.company.leave_timesheet_task_id) + + def test_post_init_hook_backfills_future_timesheets(self): + """Post-init hook must backfill missing future timesheets for existing data.""" + line = self._create_public_holiday_line(date(2026, 8, 4), self.country_a) + timesheets = self.env["account.analytic.line"].search( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + self.assertTrue(timesheets) + + timesheets.write({"public_holiday_line_id": False}) + timesheets.unlink() + self.assertFalse( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + ) + + post_init_hook(self.env) + + self.assertTrue( + self.env["account.analytic.line"].search_count( + [ + ("employee_id", "=", self.emp_a.id), + ("public_holiday_line_id", "=", line.id), + ] + ) + )