diff --git a/.copier-answers.yml b/.copier-answers.yml index 79f9e2bb1..8ef2f08a6 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -17,8 +17,8 @@ odoo_version: 16.0 org_name: Odoo Community Association (OCA) org_slug: OCA rebel_module_groups: [] -repo_description: 'TODO: add repo description.' -repo_name: hr-holidays +repo_description: All functionality having to do with employee leave management. +repo_name: Modules to enhance employee leave functionality repo_slug: hr-holidays repo_website: https://github.com/OCA/hr-holidays use_pyproject_toml: false diff --git a/.gitignore b/.gitignore index 6ec07a054..5ec3d6ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ docs/_build/ # OCA rules !static/lib/ + +# AI Assistents +CLAUDE.md diff --git a/README.md b/README.md index 7e665e2d1..24fd89198 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Support the OCA](https://odoo-community.org/readme-banner-image)](https://odoo-community.org/get-involved?utm_source=repo-readme) -# hr-holidays +# Modules to enhance employee leave functionality [![Runboat](https://img.shields.io/badge/runboat-Try%20me-875A7B.png)](https://runboat.odoo-community.org/builds?repo=OCA/hr-holidays&target_branch=16.0) [![Pre-commit Status](https://github.com/OCA/hr-holidays/actions/workflows/pre-commit.yml/badge.svg?branch=16.0)](https://github.com/OCA/hr-holidays/actions/workflows/pre-commit.yml?query=branch%3A16.0) [![Build Status](https://github.com/OCA/hr-holidays/actions/workflows/test.yml/badge.svg?branch=16.0)](https://github.com/OCA/hr-holidays/actions/workflows/test.yml?query=branch%3A16.0) @@ -10,7 +10,7 @@ -TODO: add repo description. +All functionality having to do with employee leave management. diff --git a/hr_holidays_work_and_leaves/README.rst b/hr_holidays_work_and_leaves/README.rst new file mode 100644 index 000000000..aa1334177 --- /dev/null +++ b/hr_holidays_work_and_leaves/README.rst @@ -0,0 +1,84 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +hr_holidays_work_and_leaves +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7baf3344b11f0fcccea1cededc8b65e15ea3eefeb10c11f8cd972a64658001ae + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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--holidays-lightgray.png?logo=github + :target: https://github.com/OCA/hr-holidays/tree/16.0/hr_holidays_work_and_leaves + :alt: OCA/hr-holidays +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-holidays-16-0/hr-holidays-16-0-hr_holidays_work_and_leaves + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/hr-holidays&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module was written to get a clear overview from leaves and +remaining work hours in a period. + +**Table of contents** + +.. contents:: + :local: + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Therp BV + +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. + +.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px + :target: https://github.com/NL66278 + :alt: NL66278 + +Current `maintainer `__: + +|maintainer-NL66278| + +This module is part of the `OCA/hr-holidays `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_holidays_work_and_leaves/__init__.py b/hr_holidays_work_and_leaves/__init__.py new file mode 100644 index 000000000..23ebc16cd --- /dev/null +++ b/hr_holidays_work_and_leaves/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models, wizards diff --git a/hr_holidays_work_and_leaves/__manifest__.py b/hr_holidays_work_and_leaves/__manifest__.py new file mode 100644 index 000000000..c66c37be0 --- /dev/null +++ b/hr_holidays_work_and_leaves/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "hr_holidays_work_and_leaves", + "summary": "Get real working hours within a period.", + "author": "Therp BV, Odoo Community Association (OCA)", + "maintainers": ["NL66278"], + "website": "https://github.com/OCA/hr-holidays", + "license": "AGPL-3", + "category": "Human Resources", + "version": "16.0.1.0.0", + "depends": ["hr_holidays", "hr_holidays_public"], + "data": [ + "security/ir.model.access.csv", + "wizards/hr_employee_planning_views.xml", + "views/hr_employee_views.xml", + ], + "demo": [ + "demo/demo.xml", + ], + "installable": True, +} diff --git a/hr_holidays_work_and_leaves/demo/demo.xml b/hr_holidays_work_and_leaves/demo/demo.xml new file mode 100644 index 000000000..f98c80f5a --- /dev/null +++ b/hr_holidays_work_and_leaves/demo/demo.xml @@ -0,0 +1,28 @@ + + + diff --git a/hr_holidays_work_and_leaves/models/__init__.py b/hr_holidays_work_and_leaves/models/__init__.py new file mode 100644 index 000000000..92ea155c7 --- /dev/null +++ b/hr_holidays_work_and_leaves/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import hr_employee diff --git a/hr_holidays_work_and_leaves/models/hr_employee.py b/hr_holidays_work_and_leaves/models/hr_employee.py new file mode 100644 index 000000000..c14340ca3 --- /dev/null +++ b/hr_holidays_work_and_leaves/models/hr_employee.py @@ -0,0 +1,549 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +"""Get a real overview of leaves and work hours for an employee in a period.""" +import dataclasses +import logging +from datetime import datetime, time, timedelta + +import pytz + +from odoo import _, models + +from ..utils.employee_day import EmployeeDay, EmployeeDaySchedule, TimeSlot, WorkEntry +from ..utils.peekable_iterator import PeekableIterator + +_logger = logging.getLogger(__name__) + + +class HrEmployee(models.Model): + """Show advisor weekly planning.""" + + _inherit = "hr.employee" + + def action_show_planning(self): + self.ensure_one() + wizard = self.env["hr.employee.planning"].create({"employee_id": self.id}) + return { + "type": "ir.actions.act_window", + "res_model": "hr.employee.planning", + "res_id": wizard.id, + "view_mode": "form", + "target": "new", + } + + def _get_full_schedule_per_day(self, start_datetime, end_datetime): + """For an employee, get information on leaves, appointments and work per day. + + First get information on all leave and work entries on a day, then add + information on requested (but not yet validated) leaves and appointments. + + Appointments are only valid when overlapping with work hours, and not + private. + """ + self.ensure_one() + local_timezone = pytz.timezone(self.tz) + schedule = self._get_work_hours_and_leaves_per_day(start_datetime, end_datetime) + requested_leaves = self.env["hr.leave"].search( + self._get_schedule_leave_requested_domain(start_datetime, end_datetime), + order="date_from", + ) + for leave in requested_leaves: + date_from = leave.date_from.replace(tzinfo=pytz.utc).astimezone( + local_timezone + ) + date_to = leave.date_to.replace(tzinfo=pytz.utc).astimezone(local_timezone) + schedule.add_to_days( + date_from, date_to, "leave_requested", leave.private_name or _("Leave") + ) + if self.user_id: + events = self.env["calendar.event"].search( + self._get_schedule_appointment_domain(start_datetime, end_datetime), + order="start", + ) + for event in events: + date_from = event.start.replace(tzinfo=pytz.utc).astimezone( + local_timezone + ) + date_to = event.stop.replace(tzinfo=pytz.utc).astimezone(local_timezone) + schedule.add_to_days(date_from, date_to, "appointment", event.name) + return schedule + + def _get_schedule_leave_requested_domain(self, start_datetime, end_datetime): + return [ + ("employee_id", "=", self.id), + ("state", "not in", ["validate", "refuse"]), + ("date_from", "<", end_datetime), + ("date_to", ">", start_datetime), + ] + + def _get_schedule_appointment_domain(self, start_datetime, end_datetime): + return [ + ("partner_ids", "in", self.user_id.partner_id.ids), + ("privacy", "!=", "private"), + ("show_as", "=", "busy"), + ("start", "<", end_datetime), + ("stop", ">", start_datetime), + ] + + def _get_work_hours_and_leaves_per_day(self, start_datetime, end_datetime): + """For a single employee return an EmployeeDaySchedule, ordered on date. + + Covers all dates with scheduled work or approved leave between + start_datetime and end_datetime (inclusive). + + Each EmployeeDay object has the following attributes: + - date (datetime.date) + - hours_work (float) + - hours_leave (float) + - day_schedule (list of TimeSlot) + + Each entry in day_schedule is a TimeSlot object with attributes: + - start_time (datetime.time) + - end_time (datetime.time) + - type ("work" or "leave") + + Example: + EmployeeDaySchedule([ + EmployeeDay( + date=date(2026, 5, 21), + hours_work=8.0, + hours_leave=0.0, + day_schedule=[ + TimeSlot(time(9, 0), time(17, 0), "work"), + ], + ), + EmployeeDay( + date=date(2026, 5, 22), + hours_work=4.0, + hours_leave=4.0, + day_schedule=[ + TimeSlot(time(9, 0), time(11, 0), "work"), + TimeSlot(time(11, 0), time(15, 0), "leave"), + TimeSlot(time(15, 0), time(17, 0), "work"), + ], + ), + ]) + """ + self.ensure_one() + work_hours_and_leaves_per_day = [] + work_and_leaves = self._get_work_hours_and_leaves(start_datetime, end_datetime) + current_day = None + for entry in work_and_leaves: + entry_date = entry.datetime_from.date() + if current_day is None or current_day.date != entry_date: + current_day = EmployeeDay(entry_date) + work_hours_and_leaves_per_day.append(current_day) + if entry.type == "work": + current_day.hours_work += entry.duration + elif entry.type == "holiday": + current_day.hours_holiday += entry.duration + else: + current_day.hours_leave += entry.duration + current_day.day_schedule.append( + TimeSlot( + entry.datetime_from.time(), + entry.datetime_to.time(), + entry.type, + name=entry.name, + ) + ) + return EmployeeDaySchedule(work_hours_and_leaves_per_day) + + def _get_work_hours_and_leaves(self, start_datetime, end_datetime): + """Return a merged, chronological list of WorkEntry objects. + + Calls _get_work_per_day and _get_leaves_per_day (which already merges + approved leaves and public holidays, with holidays taking precedence), + then advances both generators in parallel — similar to a merge-sort step + — comparing start datetimes to decide which entry to emit next. + + Overlapping intervals are split so that work and leave/holiday blocks + never overlap in the output. The six overlap cases handled are: + + 1. Leave ends before work starts: discard the leave entry and advance + the leave iterator (the leave was already emitted as a per-slot copy + or belongs to a non-working day). + 2. Work ends before leave starts: emit the work slot as-is and advance + the work iterator. + 3. Leave fully covers work (leave_from <= work_from AND + leave_to >= work_to): emit a per-slot copy of the leave clipped to + the work slot's hours so that _get_work_hours_and_leaves_per_day + places it on the correct date. Advance work only; the same leave + may cover subsequent work slots. + 4. Leave is entirely within work (leave_from >= work_from AND + leave_to <= work_to): emit work before the leave (if any), then + the leave. Trim work to start at leave_to (or advance work if + leave_to == work_to) and advance leave; the loop then matches the + remaining work against subsequent leaves, correctly handling + multiple interruptions within one work slot. + 5. Leave starts before work and ends during it: emit a clipped copy + of the leave starting at work_from. Trim work to start at + leave_to (or advance work if leave_to == work_to) and advance + leave; the loop then handles any further interruptions. + 6. Leave starts during work and extends beyond it: emit work up to + leave_from, then emit the leave clipped to work_to. Trim the + leave to start at work_to and advance work; the trimmed leave will + be matched against subsequent work slots (or discarded by Case 1 if + it ends within the same day's non-working hours). + + start_datetime and end_datetime must be naive UTC datetimes. + Each entry in the returned list is a WorkEntry with attributes type + ("work", "leave", or "holiday"), datetime_from, datetime_to, and + optional holiday_name / holiday_status_id. Duration is available via + the WorkEntry.duration property. + """ + self.ensure_one() + self._check_resource_calendar() + work_and_leaves = [] + work_days = PeekableIterator( + self._get_work_per_day(start_datetime, end_datetime) + ) + leave_days = PeekableIterator( + self._get_leaves_per_day(start_datetime, end_datetime) + ) + work = work_days.next() if work_days.hasNext() else None + leave = leave_days.next() if leave_days.hasNext() else None + while work is not None or leave is not None: + if work is None: + # All work slots processed; remaining leaves don't affect any slot. + break + if leave is None: + # No more leaves; flush remaining work. + work_and_leaves.append(work) + work = work_days.next() if work_days.hasNext() else None + elif leave.datetime_to <= work.datetime_from: + # Leave ended before this work slot; discard it. + leave = leave_days.next() if leave_days.hasNext() else None + elif leave.datetime_from >= work.datetime_to: + # Leave starts after this work slot ends; emit work, keep leave. + work_and_leaves.append(work) + work = work_days.next() if work_days.hasNext() else None + elif ( + leave.datetime_from <= work.datetime_from + and leave.datetime_to >= work.datetime_to + ): + # Leave fully covers this work slot. + # Emit a per-slot copy clipped to work hours so that + # _get_work_hours_and_leaves_per_day places it on the right date. + work_and_leaves.append( + dataclasses.replace( + leave, + datetime_from=work.datetime_from, + datetime_to=work.datetime_to, + ) + ) + work = work_days.next() if work_days.hasNext() else None + # Do not advance leave; it may cover subsequent work slots. + elif ( + leave.datetime_from >= work.datetime_from + and leave.datetime_to <= work.datetime_to + ): + # Leave is entirely within this work slot. + if leave.datetime_from > work.datetime_from: + work_and_leaves.append( + dataclasses.replace(work, datetime_to=leave.datetime_from) + ) + work_and_leaves.append(leave) + # Trim work to start after the leave; subsequent leaves may + # still interrupt the remaining portion of this work slot. + if leave.datetime_to < work.datetime_to: + work = dataclasses.replace(work, datetime_from=leave.datetime_to) + else: + work = work_days.next() if work_days.hasNext() else None + leave = leave_days.next() if leave_days.hasNext() else None + elif leave.datetime_from < work.datetime_from: + # Leave started before this work slot and ends during it. + work_and_leaves.append( + dataclasses.replace(leave, datetime_from=work.datetime_from) + ) + # Trim work to start after the leave; subsequent leaves may + # still interrupt the remaining portion of this work slot. + if leave.datetime_to < work.datetime_to: + work = dataclasses.replace(work, datetime_from=leave.datetime_to) + else: + work = work_days.next() if work_days.hasNext() else None + leave = leave_days.next() if leave_days.hasNext() else None + else: + # Leave starts during this work slot and extends beyond it. + # Emit work up to where the leave starts. + work_and_leaves.append( + dataclasses.replace(work, datetime_to=leave.datetime_from) + ) + # Emit the leave portion that falls within this work slot. + work_and_leaves.append( + dataclasses.replace(leave, datetime_to=work.datetime_to) + ) + # Trim leave to start after this work slot; it may still cover + # subsequent work slots (e.g. multi-day leave), or will be + # discarded by Case 1 if it ends in non-working hours. + leave = dataclasses.replace(leave, datetime_from=work.datetime_to) + work = work_days.next() if work_days.hasNext() else None + return work_and_leaves + + def _check_resource_calendar(self): + """Warn if the employee's user timezone differs from the work calendar timezone.""" + self.ensure_one() + if ( + self.user_id + and self.user_id.partner_id.tz + and self.user_id.partner_id.tz != self.resource_calendar_id.tz + ): + _logger.warning( + "Employee %(employee)s timezone %(employee_tz)s not the same" + " as calendar timezone %(calendar_tz)s", + { + "employee": self.name, + "employee_tz": self.user_id.partner_id.tz, + "calendar_tz": self.resource_calendar_id.tz, + }, + ) + + def _get_work_per_day(self, start_datetime, end_datetime): + """Yield working times one by one from start to end.""" + self.ensure_one() + datetime_from = self._get_employee_datetime(start_datetime) + date_from = datetime_from.date() + datetime_to = self._get_employee_datetime(end_datetime) + date_to = datetime_to.date() + Attendance = self.env["resource.calendar.attendance"] + attendances = Attendance.search( + [ + ("calendar_id", "=", self.resource_calendar_id.id), + ("display_type", "=", False), + "|", + ("date_from", "=", False), + ("date_from", ">=", date_from), + "|", + ("date_to", "=", False), + ("date_to", ">=", date_to), + ], + order="week_type, dayofweek, hour_from", + ) + attendance_dict = {} + for attendance in attendances: + attendance_key = (attendance.week_type, attendance.dayofweek) + if attendance_key not in attendance_dict: + attendance_dict[attendance_key] = [] + attendance_dict[attendance_key].append(attendance) + current_date = date_from + while current_date <= date_to: + attendance_list = attendance_dict.get( + self._get_date_attendance_key(current_date), [] + ) + for attendance in attendance_list: + yield self._fill_work_hours(current_date, attendance) + current_date += timedelta(days=1) + + def _get_employee_datetime(self, utc_datetime): + """Convert a naive datetime, known to be utc, to a localized datetime.""" + self.ensure_one() + local_timezone = pytz.timezone(self.tz) + return utc_datetime.replace(tzinfo=pytz.utc).astimezone(local_timezone) + + def _get_date_attendance_key(self, date): + """Attendance key depends on odd/even week and day of week.""" + # week_type 0 is even week, 1 is odd week. Only for two week calendars. + dayofweek = str(date.weekday()) + calendar = self.resource_calendar_id + # Odoo has its own definition of what are odd or even weeks, that + # can differ from what you would guess from the weeknumber!! + week_type = ( + str(self.env["resource.calendar.attendance"].get_week_type(date)) + if calendar.two_weeks_calendar + else False + ) + return (week_type, dayofweek) + + def _fill_work_hours(self, date, attendance): + hours_from, minutes_from = self._time_to_hours_minutes(attendance.hour_from) + hours_to, minutes_to = self._time_to_hours_minutes(attendance.hour_to) + datetime_from = datetime.combine(date, time(hours_from, minutes_from)) + datetime_to = datetime.combine(date, time(hours_to, minutes_to)) + local_timezone = pytz.timezone(self.tz) + return WorkEntry( + type="work", + datetime_from=local_timezone.localize(datetime_from), + datetime_to=local_timezone.localize(datetime_to), + name=attendance.name, + ) + + def _time_to_hours_minutes(self, float_value): + hours = int(float_value) + minutes = int((float_value - hours) * 60) + return hours, minutes + + def _get_leaves_per_day(self, start_datetime, end_datetime): + """Yield approved leaves and public holidays from start to end. + + Public holidays take precedence: when a leave overlaps with a public + holiday, the holiday is returned for that portion and the leave is + trimmed around it. Entries are yielded in ascending datetime_from + order. + + For full-day leaves (is_full_day=True), one midnight-to-midnight entry + is generated per calendar day so the merge algorithm clips each day to + the *current* work schedule. This keeps results correct even when the + work schedule is modified after the leave was registered. Partial + leaves use the exact stored start/end times. + """ + self.ensure_one() + local_timezone = pytz.timezone(self.tz) + start_date = self._get_employee_datetime(start_datetime).date() + end_date = self._get_employee_datetime(end_datetime).date() + # Build holiday entries (full calendar day: 00:00 → 00:00 next day). + holiday_entries = [] + holiday_dates = set() + for line in self._get_public_holiday_lines(start_date, end_date): + date_from = local_timezone.localize(datetime.combine(line.date, time.min)) + date_to = local_timezone.localize( + datetime.combine(line.date + timedelta(days=1), time.min) + ) + holiday_entries.append( + WorkEntry( + type="holiday", + datetime_from=date_from, + datetime_to=date_to, + holiday_name=line.name, + name=line.name, + ) + ) + holiday_dates.add(line.date) + # Build leave entries split around any overlapping holiday days. + leave_entries = [] + leaves = self.env["hr.leave"].search( + [ + ("employee_id", "=", self.id), + ("state", "=", "validate"), + ("date_from", "<", end_datetime), + ("date_to", ">", start_datetime), + ], + order="date_from", + ) + for leave in leaves: + date_from = leave.date_from.replace(tzinfo=pytz.utc).astimezone( + local_timezone + ) + date_to = leave.date_to.replace(tzinfo=pytz.utc).astimezone(local_timezone) + if not leave.request_unit_half and not leave.request_unit_hours: + # Generate one midnight-to-midnight entry per calendar day so + # the merge algorithm clips it to the current work schedule, + # regardless of schedule changes since registration. + # A leave ending exactly at midnight belongs to the previous + # day (e.g. date_to Tue 00:00 means the last active day is Mon). + leave_end_date = ( + date_to.date() - timedelta(days=1) + if date_to.time() == time.min + else date_to.date() + ) + current_date = date_from.date() + while current_date <= leave_end_date: + day_from = local_timezone.localize( + datetime.combine(current_date, time.min) + ) + day_to = local_timezone.localize( + datetime.combine(current_date + timedelta(days=1), time.min) + ) + entry = WorkEntry( + type="leave", + datetime_from=day_from, + datetime_to=day_to, + holiday_status_id=leave.holiday_status_id, + name=leave.private_name or _("Leave"), + ) + leave_entries.extend( + self._split_entry_around_holidays( + entry, holiday_dates, local_timezone + ) + ) + current_date += timedelta(days=1) + else: + entry = WorkEntry( + type="leave", + datetime_from=date_from, + datetime_to=date_to, + holiday_status_id=leave.holiday_status_id, + name=leave.private_name or _("Leave"), + ) + leave_entries.extend( + self._split_entry_around_holidays( + entry, holiday_dates, local_timezone + ) + ) + yield from sorted( + holiday_entries + leave_entries, + key=lambda e: e.datetime_from, + ) + + def _get_public_holiday_lines(self, date_from, date_to): + """Return public holiday lines applicable to this employee in the date range. + + Iterates over every calendar year that overlaps [date_from, date_to]. + For each year, calls ``hr.holidays.public.get_holidays_list(year, + employee=self)``, which already restricts results to holidays that apply + to the employee's country and state. The per-year results are then + filtered a second time to keep only lines whose date falls within the + requested range (necessary because ``get_holidays_list`` returns all + holidays for the full year). Lines from all years are combined and + returned sorted ascending by date. + """ + HrHolidaysPublic = self.env["hr.holidays.public"] + lines = self.env["hr.holidays.public.line"].browse() + for year in range(date_from.year, date_to.year + 1): + year_lines = HrHolidaysPublic.get_holidays_list(year, employee_id=self.id) + lines |= year_lines.filtered( + lambda l, f=date_from, t=date_to: f <= l.date <= t + ) + return lines.sorted("date") + + def _split_entry_around_holidays(self, entry, holiday_dates, local_timezone): + """Return copies of entry with any holiday-day portions removed. + + Finds all dates in *holiday_dates* that fall within the entry's date + range, then walks through them in chronological order. For each + holiday date: + + - If the current tail of the entry starts before the holiday's + midnight, a copy of the entry is emitted covering + [current_from, holiday midnight). + - The current position is then advanced to midnight of the day *after* + the holiday, effectively discarding that calendar day from the entry. + + After all holidays have been processed, any remaining portion + [last_holiday_end, entry end) is emitted as a final copy. + + If no holiday overlaps with the entry, the original entry is returned + unchanged inside a single-element list. If the entry falls entirely + within holiday days, all portions are discarded and an empty list is + returned. + """ + date_from = entry.datetime_from + date_to = entry.datetime_to + overlapping = sorted( + h for h in holiday_dates if date_from.date() <= h <= date_to.date() + ) + if not overlapping: + return [entry] + result = [] + current_from = date_from + for holiday_date in overlapping: + holiday_start = local_timezone.localize( + datetime.combine(holiday_date, time.min) + ) + if current_from < holiday_start: + result.append( + dataclasses.replace( + entry, + datetime_from=current_from, + datetime_to=holiday_start, + ) + ) + current_from = local_timezone.localize( + datetime.combine(holiday_date + timedelta(days=1), time.min) + ) + if current_from < date_to: + result.append( + dataclasses.replace( + entry, datetime_from=current_from, datetime_to=date_to + ) + ) + return result diff --git a/hr_holidays_work_and_leaves/readme/DESCRIPTION.md b/hr_holidays_work_and_leaves/readme/DESCRIPTION.md new file mode 100644 index 000000000..6221b4f4a --- /dev/null +++ b/hr_holidays_work_and_leaves/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module was written to get a clear overview from leaves and remaining work hours +in a period. diff --git a/hr_holidays_work_and_leaves/security/ir.model.access.csv b/hr_holidays_work_and_leaves/security/ir.model.access.csv new file mode 100644 index 000000000..f0b5d21b4 --- /dev/null +++ b/hr_holidays_work_and_leaves/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_employee_planning,hr_employee_planning,model_hr_employee_planning,base.group_user,1,1,1,1 +access_hr_employee_planning_line,hr_employee_planning_line,model_hr_employee_planning_line,base.group_user,1,1,1,1 diff --git a/hr_holidays_work_and_leaves/static/description/index.html b/hr_holidays_work_and_leaves/static/description/index.html new file mode 100644 index 000000000..40d61243f --- /dev/null +++ b/hr_holidays_work_and_leaves/static/description/index.html @@ -0,0 +1,425 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

hr_holidays_work_and_leaves

+ +

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

+

This module was written to get a clear overview from leaves and +remaining work hours in a period.

+

Table of contents

+ +
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

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.

+

Current maintainer:

+

NL66278

+

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

+

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

+
+
+
+
+ + diff --git a/hr_holidays_work_and_leaves/tests/__init__.py b/hr_holidays_work_and_leaves/tests/__init__.py new file mode 100644 index 000000000..3030befe6 --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/__init__.py @@ -0,0 +1,10 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import ( + test_employee_day, + test_get_full_schedule_per_day, + test_get_leaves_per_day, + test_get_work_hours_and_leaves, + test_get_work_per_day, + test_hr_employee_planning, +) diff --git a/hr_holidays_work_and_leaves/tests/common.py b/hr_holidays_work_and_leaves/tests/common.py new file mode 100644 index 000000000..d2b91c3da --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/common.py @@ -0,0 +1,193 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.fields import Command +from odoo.tests.common import TransactionCase + +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT + + +class WorkAndLeavesBase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT)) + cls.env["hr.holidays.public"].sudo().search([]).unlink() + # Manager who approves leaves. + cls.manager_user = cls.env["res.users"].create( + { + "name": "Test Manager", + "login": "test_manager@example.com", + "email": "test_manager@example.com", + "groups_id": [ + Command.link( + cls.env.ref("hr_holidays.group_hr_holidays_manager").id + ) + ], + } + ) + cls.manager = cls.env["hr.employee"].create( + { + "name": "Test Manager", + "user_id": cls.manager_user.id, + } + ) + calendar = cls.env["resource.calendar"].create( + { + "name": "Test 5-day Calendar", + "tz": "UTC", + } + ) + # Remove default attendances generated by Odoo, then add our own. + calendar.attendance_ids.unlink() + calendar.write( + { + "attendance_ids": [ + Command.create( + { + "name": day_name, + "dayofweek": str(day_index), + "hour_from": 9.0, + "hour_to": 17.0, + } + ) + for day_index, day_name in enumerate( + ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + ) + ] + } + ) + cls.employee = cls.env["hr.employee"].create( + { + "name": "Test Employee", + "resource_calendar_id": calendar.id, + "parent_id": cls.manager.id, + } + ) + two_week_calendar = cls.env["resource.calendar"].create( + { + "name": "Test 2-week Calendar", + "tz": "UTC", + "two_weeks_calendar": True, + } + ) + two_week_calendar.attendance_ids.unlink() + # Even week (week_type='0'): Mon–Fri, 5 days. + # Odd week (week_type='1'): Tue–Fri, 4 days (Monday off). + two_week_calendar.write( + { + "attendance_ids": [ + Command.create( + { + "name": f"Week 1 {day_name}", + "week_type": "0", + "dayofweek": str(day_index), + "hour_from": 9.0, + "hour_to": 17.0, + } + ) + for day_index, day_name in enumerate( + ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + ) + ] + + [ + Command.create( + { + "name": f"Week 2 {day_name}", + "week_type": "1", + "dayofweek": str(day_index), + "hour_from": 9.0, + "hour_to": 17.0, + } + ) + for day_index, day_name in enumerate( + ["Tuesday", "Wednesday", "Thursday", "Friday"], start=1 + ) + ] + } + ) + cls.employee_two_week = cls.env["hr.employee"].create( + { + "name": "Test Employee Two Week", + "resource_calendar_id": two_week_calendar.id, + "parent_id": cls.manager.id, + } + ) + cls.leave_type = cls.env["hr.leave.type"].create( + { + "name": "Test Leave", + "leave_validation_type": "manager", + "requires_allocation": "no", + "time_type": "leave", + } + ) + + def _make_public_holiday(self, holiday_date, name="Test Holiday"): + """Create a country-agnostic public holiday on *holiday_date*. + + Searches for an existing hr.holidays.public record for the same year + (without country) and reuses it, so multiple calls within one test + method can share the same year record without hitting the uniqueness + constraint. + """ + HrHolidaysPublic = self.env["hr.holidays.public"].sudo() + year_record = HrHolidaysPublic.search( + [("year", "=", holiday_date.year), ("country_id", "=", False)], limit=1 + ) + if not year_record: + year_record = HrHolidaysPublic.create({"year": holiday_date.year}) + return ( + self.env["hr.holidays.public.line"] + .sudo() + .create({"name": name, "date": holiday_date, "year_id": year_record.id}) + ) + + def _make_leave(self, employee, date_from, date_to, full_day=True, validate=True): + """Create a leave request, optionally validating it. + + Advances the leave through draft → confirm (→ validate when requested). + Pass full_day=False for hour-specific leaves; the stored date_from/date_to + are then recomputed from the hour fields so they reflect the exact times. + """ + leave = ( + self.env["hr.leave"] + .sudo() + .create( + { + "holiday_type": "employee", + "employee_id": employee.id, + "holiday_status_id": self.leave_type.id, + "date_from": date_from, + "date_to": date_to, + } + ) + ) + if not full_day: + h_from = date_from.hour + date_from.minute / 60.0 + h_to = date_to.hour + date_to.minute / 60.0 + leave.sudo().write( + { + "request_unit_hours": True, + "request_date_from": date_from.date(), + "request_date_to": date_to.date(), + "request_hour_from": str(h_from) + if h_from % 1 + else str(int(h_from)), + "request_hour_to": str(h_to) if h_to % 1 else str(int(h_to)), + } + ) + if leave.state == "draft": + leave.sudo().action_confirm() + if validate and leave.state in ("confirm", "validate1"): + leave.with_user(self.manager_user).action_validate() + return leave + + def _make_leave_request(self, employee, date_from, date_to, full_day=True): + """Create a confirmed but not yet validated leave request.""" + return self._make_leave( + employee, date_from, date_to, full_day=full_day, validate=False + ) + + def _make_validated_leave(self, employee, date_from, date_to, full_day=True): + return self._make_leave( + employee, date_from, date_to, full_day=full_day, validate=True + ) diff --git a/hr_holidays_work_and_leaves/tests/test_employee_day.py b/hr_holidays_work_and_leaves/tests/test_employee_day.py new file mode 100644 index 000000000..e371fb854 --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/test_employee_day.py @@ -0,0 +1,224 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import unittest +from datetime import date, datetime, time + +from ..utils.employee_day import EmployeeDay, EmployeeDaySchedule, TimeSlot + + +def _work_day(d, start_hour=9, end_hour=17): + """Return an EmployeeDay with a single work slot.""" + day = EmployeeDay(d) + day.day_schedule.append(TimeSlot(time(start_hour, 0), time(end_hour, 0), "work")) + return day + + +class TestEmployeeDayInsertSlot(unittest.TestCase): + def test_insert_into_empty_schedule(self): + day = EmployeeDay(date(2026, 6, 1)) + slot = TimeSlot(time(10, 0), time(11, 0), "leave") + day.insert_slot(slot) + self.assertEqual(day.day_schedule, [slot]) + + def test_insert_before_existing(self): + day = EmployeeDay(date(2026, 6, 1)) + late = TimeSlot(time(14, 0), time(15, 0), "leave") + early = TimeSlot(time(10, 0), time(11, 0), "leave") + day.insert_slot(late) + day.insert_slot(early) + self.assertEqual(day.day_schedule, [early, late]) + + def test_insert_after_existing(self): + day = EmployeeDay(date(2026, 6, 1)) + early = TimeSlot(time(10, 0), time(11, 0), "leave") + late = TimeSlot(time(14, 0), time(15, 0), "leave") + day.insert_slot(early) + day.insert_slot(late) + self.assertEqual(day.day_schedule, [early, late]) + + def test_insert_in_middle(self): + day = EmployeeDay(date(2026, 6, 1)) + first = TimeSlot(time(9, 0), time(10, 0), "work") + third = TimeSlot(time(14, 0), time(17, 0), "work") + middle = TimeSlot(time(11, 0), time(13, 0), "leave") + day.insert_slot(first) + day.insert_slot(third) + day.insert_slot(middle) + self.assertEqual(day.day_schedule, [first, middle, third]) + + +class TestEmployeeDayComputeSlotWorkOverlap(unittest.TestCase): + def test_no_work_slots_returns_zero(self): + day = EmployeeDay(date(2026, 6, 1)) + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(10, 0), time(11, 0)), 0.0 + ) + + def test_non_work_slots_are_ignored(self): + day = EmployeeDay(date(2026, 6, 1)) + day.day_schedule.append(TimeSlot(time(9, 0), time(17, 0), "leave")) + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(10, 0), time(11, 0)), 0.0 + ) + + def test_slot_fully_within_work(self): + day = _work_day(date(2026, 6, 1)) + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(10, 0), time(12, 0)), 2.0 + ) + + def test_slot_fully_outside_work(self): + day = _work_day(date(2026, 6, 1)) + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(18, 0), time(19, 0)), 0.0 + ) + + def test_slot_overlaps_work_start(self): + day = _work_day(date(2026, 6, 1)) + # 07:00–10:00, work starts at 09:00 → 1 hour overlap. + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(7, 0), time(10, 0)), 1.0 + ) + + def test_slot_overlaps_work_end(self): + day = _work_day(date(2026, 6, 1)) + # 16:00–19:00, work ends at 17:00 → 1 hour overlap. + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(16, 0), time(19, 0)), 1.0 + ) + + def test_midnight_to_midnight_covers_full_work_day(self): + """time(0, 0) as both from and to is treated as 00:00–24:00.""" + day = _work_day(date(2026, 6, 1)) + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(0, 0), time(0, 0)), 8.0 + ) + + def test_multiple_work_slots_accumulates(self): + """Overlap is summed across all work slots.""" + day = EmployeeDay(date(2026, 6, 1)) + day.day_schedule.append(TimeSlot(time(9, 0), time(12, 0), "work")) + day.day_schedule.append(TimeSlot(time(13, 0), time(17, 0), "work")) + # 10:00–16:00: overlaps 10–12 (2h) and 13–16 (3h) = 5h. + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(10, 0), time(16, 0)), 5.0 + ) + + def test_slot_spanning_lunch_gap_excludes_gap(self): + """A slot spanning a gap between two work blocks excludes the gap.""" + day = EmployeeDay(date(2026, 6, 1)) + day.day_schedule.append(TimeSlot(time(9, 0), time(12, 0), "work")) + day.day_schedule.append(TimeSlot(time(13, 0), time(17, 0), "work")) + # 11:00–14:00: overlaps 11–12 (1h) and 13–14 (1h) = 2h, gap excluded. + self.assertAlmostEqual( + day.compute_slot_work_overlap(time(11, 0), time(14, 0)), 2.0 + ) + + +class TestEmployeeDaySchedule(unittest.TestCase): + def test_iter_empty(self): + self.assertEqual(list(EmployeeDaySchedule([])), []) + + def test_iter_preserves_order(self): + days = [ + _work_day(date(2026, 6, 1)), + _work_day(date(2026, 6, 2)), + _work_day(date(2026, 6, 3)), + ] + self.assertEqual(list(EmployeeDaySchedule(days)), days) + + def test_add_to_days_no_overlap_is_ignored(self): + day = _work_day(date(2026, 6, 1)) + schedule = EmployeeDaySchedule([day]) + schedule.add_to_days( + datetime(2026, 6, 1, 7, 0), + datetime(2026, 6, 1, 8, 0), + "appointment", + "Early meeting", + ) + self.assertEqual( + [ts for ts in day.day_schedule if ts.type == "appointment"], [] + ) + self.assertAlmostEqual(day.hours_appointment, 0.0) + + def test_add_to_days_within_work_adds_appointment(self): + day = _work_day(date(2026, 6, 1)) + schedule = EmployeeDaySchedule([day]) + schedule.add_to_days( + datetime(2026, 6, 1, 10, 0), + datetime(2026, 6, 1, 11, 0), + "appointment", + "Team meeting", + ) + appts = [ts for ts in day.day_schedule if ts.type == "appointment"] + self.assertEqual(len(appts), 1) + self.assertEqual(appts[0].start_time, time(10, 0)) + self.assertEqual(appts[0].end_time, time(11, 0)) + self.assertAlmostEqual(appts[0].hours_overlap_work, 1.0) + self.assertAlmostEqual(day.hours_appointment, 1.0) + + def test_add_to_days_leave_requested_accumulates(self): + day = _work_day(date(2026, 6, 1)) + schedule = EmployeeDaySchedule([day]) + schedule.add_to_days( + datetime(2026, 6, 1, 10, 0), + datetime(2026, 6, 1, 12, 0), + "leave_requested", + "Doctor", + ) + self.assertAlmostEqual(day.hours_leave_requested, 2.0) + self.assertAlmostEqual(day.hours_appointment, 0.0) + + def test_add_to_days_unknown_type_does_not_accumulate(self): + """Unrecognised slot types are inserted but don't touch hours counters.""" + day = _work_day(date(2026, 6, 1)) + schedule = EmployeeDaySchedule([day]) + schedule.add_to_days( + datetime(2026, 6, 1, 10, 0), + datetime(2026, 6, 1, 11, 0), + "other", + "Something", + ) + self.assertAlmostEqual(day.hours_leave_requested, 0.0) + self.assertAlmostEqual(day.hours_appointment, 0.0) + + def test_add_to_days_date_not_in_schedule_is_ignored(self): + day = _work_day(date(2026, 6, 1)) + schedule = EmployeeDaySchedule([day]) + schedule.add_to_days( + datetime(2026, 6, 2, 10, 0), + datetime(2026, 6, 2, 11, 0), + "appointment", + "Other day", + ) + self.assertAlmostEqual(day.hours_appointment, 0.0) + + def test_add_to_days_multiday_slot_splits_across_days(self): + """A multi-day slot contributes the correct overlap to each day.""" + mon = _work_day(date(2026, 6, 1)) + tue = _work_day(date(2026, 6, 2)) + schedule = EmployeeDaySchedule([mon, tue]) + # Mon 16:00 → Tue 11:00. + schedule.add_to_days( + datetime(2026, 6, 1, 16, 0), + datetime(2026, 6, 2, 11, 0), + "leave_requested", + "Two-day leave", + ) + self.assertAlmostEqual(mon.hours_leave_requested, 1.0) # 16:00–17:00 + self.assertAlmostEqual(tue.hours_leave_requested, 2.0) # 09:00–11:00 + + def test_add_to_days_midnight_end_date_belongs_to_previous_day(self): + """date_to with time 00:00 is treated as end-of-previous-day, not start-of-next.""" + mon = _work_day(date(2026, 6, 1)) + tue = _work_day(date(2026, 6, 2)) + schedule = EmployeeDaySchedule([mon, tue]) + # Slot ends exactly at midnight: should cover Mon only. + schedule.add_to_days( + datetime(2026, 6, 1, 16, 0), + datetime(2026, 6, 2, 0, 0), + "leave_requested", + "Ends at midnight", + ) + self.assertAlmostEqual(mon.hours_leave_requested, 1.0) + self.assertAlmostEqual(tue.hours_leave_requested, 0.0) diff --git a/hr_holidays_work_and_leaves/tests/test_get_full_schedule_per_day.py b/hr_holidays_work_and_leaves/tests/test_get_full_schedule_per_day.py new file mode 100644 index 000000000..390198a64 --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/test_get_full_schedule_per_day.py @@ -0,0 +1,162 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import date, datetime, time + +from .common import WorkAndLeavesBase + + +class TestGetFullSchedulePerDay(WorkAndLeavesBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee_user = cls.env["res.users"].create( + { + "name": "Employee User", + "login": "employee_user@example.com", + "email": "employee_user@example.com", + } + ) + cls.employee.write({"user_id": cls.employee_user.id}) + + def _create_event(self, name, start, stop, privacy="public", show_as="busy"): + return self.env["calendar.event"].create( + { + "name": name, + "start": start, + "stop": stop, + "privacy": privacy, + "show_as": show_as, + "partner_ids": [(4, self.employee_user.partner_id.id)], + } + ) + + def _full_schedule(self): + """Return the full schedule for the week 2026-06-08 to 2026-06-12.""" + return self.employee._get_full_schedule_per_day( + datetime(2026, 6, 8, 0, 0, 0), + datetime(2026, 6, 12, 23, 59, 59), + ) + + def test_private_appointment_is_ignored(self): + """A private calendar event must not appear in the schedule.""" + self._create_event( + "Private Meeting", + datetime(2026, 6, 10, 10, 0, 0), + datetime(2026, 6, 10, 11, 0, 0), + privacy="private", + ) + result = self._full_schedule() + wed = next(d for d in result if d.date == date(2026, 6, 10)) + self.assertEqual( + [ts for ts in wed.day_schedule if ts.type == "appointment"], [] + ) + self.assertAlmostEqual(wed.hours_appointment, 0.0) + + def test_free_appointment_is_ignored(self): + """A calendar event with show_as='free' must not appear in the schedule.""" + self._create_event( + "Free Slot", + datetime(2026, 6, 10, 10, 0, 0), + datetime(2026, 6, 10, 11, 0, 0), + show_as="free", + ) + result = self._full_schedule() + wed = next(d for d in result if d.date == date(2026, 6, 10)) + self.assertEqual( + [ts for ts in wed.day_schedule if ts.type == "appointment"], [] + ) + self.assertAlmostEqual(wed.hours_appointment, 0.0) + + def test_appointment_outside_work_is_ignored(self): + """An appointment with no overlap with any work slot must not appear.""" + self._create_event( + "After-hours Meeting", + datetime(2026, 6, 10, 18, 0, 0), + datetime(2026, 6, 10, 19, 0, 0), + ) + result = self._full_schedule() + wed = next(d for d in result if d.date == date(2026, 6, 10)) + self.assertEqual( + [ts for ts in wed.day_schedule if ts.type == "appointment"], [] + ) + self.assertAlmostEqual(wed.hours_appointment, 0.0) + + def test_appointment_within_work(self): + """An appointment entirely inside a work slot appears unclipped with full overlap.""" + self._create_event( + "Team Meeting", + datetime(2026, 6, 8, 10, 0, 0), + datetime(2026, 6, 8, 11, 0, 0), + ) + result = self._full_schedule() + mon = next(d for d in result if d.date == date(2026, 6, 8)) + slots = [ts for ts in mon.day_schedule if ts.type == "appointment"] + self.assertEqual(len(slots), 1) + self.assertEqual(slots[0].start_time, time(10, 0)) + self.assertEqual(slots[0].end_time, time(11, 0)) + self.assertAlmostEqual(slots[0].hours_overlap_work, 1.0) + self.assertAlmostEqual(mon.hours_appointment, 1.0) + + def test_appointment_overlapping_work_start(self): + """Appointment starting before work appears unclipped; overlap = work portion only.""" + self._create_event( + "Early Meeting", + datetime(2026, 6, 9, 8, 0, 0), + datetime(2026, 6, 9, 10, 0, 0), + ) + result = self._full_schedule() + tue = next(d for d in result if d.date == date(2026, 6, 9)) + slots = [ts for ts in tue.day_schedule if ts.type == "appointment"] + self.assertEqual(len(slots), 1) + self.assertEqual(slots[0].start_time, time(8, 0)) + self.assertEqual(slots[0].end_time, time(10, 0)) + self.assertAlmostEqual(slots[0].hours_overlap_work, 1.0) + self.assertAlmostEqual(tue.hours_appointment, 1.0) + + def test_appointment_overlapping_work_end(self): + """An appointment ending after work appears unclipped; overlap counts up to work end.""" + self._create_event( + "Late Meeting", + datetime(2026, 6, 11, 16, 0, 0), + datetime(2026, 6, 11, 18, 0, 0), + ) + result = self._full_schedule() + thu = next(d for d in result if d.date == date(2026, 6, 11)) + slots = [ts for ts in thu.day_schedule if ts.type == "appointment"] + self.assertEqual(len(slots), 1) + self.assertEqual(slots[0].start_time, time(16, 0)) + self.assertEqual(slots[0].end_time, time(18, 0)) + self.assertAlmostEqual(slots[0].hours_overlap_work, 1.0) + self.assertAlmostEqual(thu.hours_appointment, 1.0) + + def test_requested_leave_outside_work_is_ignored(self): + """A requested leave with no overlap with any work slot must not appear.""" + self._make_leave_request( + self.employee, + datetime(2026, 6, 10, 7, 0, 0), + datetime(2026, 6, 10, 9, 0, 0), + full_day=False, + ) + result = self._full_schedule() + wed = next(d for d in result if d.date == date(2026, 6, 10)) + self.assertEqual( + [ts for ts in wed.day_schedule if ts.type == "leave_requested"], [] + ) + self.assertAlmostEqual(wed.hours_leave_requested, 0.0) + + def test_requested_leave_overlapping_work(self): + """A requested leave starting before work appears unclipped; overlap is counted.""" + self._make_leave_request( + self.employee, + datetime(2026, 6, 8, 7, 0, 0), + datetime(2026, 6, 8, 11, 0, 0), + full_day=False, + ) + result = self._full_schedule() + mon = next(d for d in result if d.date == date(2026, 6, 8)) + slots = [ts for ts in mon.day_schedule if ts.type == "leave_requested"] + self.assertEqual(len(slots), 1) + self.assertEqual(slots[0].start_time, time(7, 0)) + self.assertEqual(slots[0].end_time, time(11, 0)) + self.assertAlmostEqual(slots[0].hours_overlap_work, 2.0) + self.assertAlmostEqual(mon.hours_leave_requested, 2.0) diff --git a/hr_holidays_work_and_leaves/tests/test_get_leaves_per_day.py b/hr_holidays_work_and_leaves/tests/test_get_leaves_per_day.py new file mode 100644 index 000000000..00049724f --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/test_get_leaves_per_day.py @@ -0,0 +1,44 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import date, datetime, time + +from .common import WorkAndLeavesBase + + +class TestGetLeavesPerDay(WorkAndLeavesBase): + def test_two_leaves_returned_in_period(self): + """Full-day leave gives midnight-to-midnight entry; partial leave keeps stored times.""" + # Full-day leave: Monday 2026-05-11 09:00–17:00 UTC. + self._make_validated_leave( + self.employee, + datetime(2026, 5, 11, 9, 0, 0), + datetime(2026, 5, 11, 17, 0, 0), + ) + # Partial leave: Wednesday 2026-05-13 09:00–13:00 UTC. + self._make_validated_leave( + self.employee, + datetime(2026, 5, 13, 9, 0, 0), + datetime(2026, 5, 13, 13, 0, 0), + full_day=False, + ) + start_datetime = datetime(2026, 5, 11, 7, 0, 0) + end_datetime = datetime(2026, 5, 15, 18, 0, 0) + + leaves = list(self.employee._get_leaves_per_day(start_datetime, end_datetime)) + + self.assertEqual(len(leaves), 2) + first, second = leaves + # Full-day leave: spans the whole calendar day midnight-to-midnight so + # the merge algorithm clips it to whatever the current schedule says. + self.assertEqual(first.type, "leave") + self.assertEqual(first.datetime_from.date(), date(2026, 5, 11)) + self.assertEqual(first.datetime_from.timetz().replace(tzinfo=None), time(0, 0)) + self.assertEqual(first.datetime_to.date(), date(2026, 5, 12)) + self.assertEqual(first.datetime_to.timetz().replace(tzinfo=None), time(0, 0)) + self.assertAlmostEqual(first.duration, 24.0) + # Partial leave: stored start/end times are used unchanged. + self.assertEqual(second.type, "leave") + self.assertEqual(second.datetime_from.date(), date(2026, 5, 13)) + self.assertEqual(second.datetime_from.timetz().replace(tzinfo=None), time(9, 0)) + self.assertEqual(second.datetime_to.timetz().replace(tzinfo=None), time(13, 0)) + self.assertAlmostEqual(second.duration, 4.0) diff --git a/hr_holidays_work_and_leaves/tests/test_get_work_hours_and_leaves.py b/hr_holidays_work_and_leaves/tests/test_get_work_hours_and_leaves.py new file mode 100644 index 000000000..ef53c5b2e --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/test_get_work_hours_and_leaves.py @@ -0,0 +1,345 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import date, datetime, time + +from .common import WorkAndLeavesBase + + +class TestGetWorkHoursAndLeaves(WorkAndLeavesBase): + def test_five_day_week_with_full_and_partial_leave(self): + """Even week (5 days): full Mon leave + Fri half-day leave → 2 leave, 5 work.""" + # ISO week 20 (even, week_type 0): Mon–Fri all working. + # Full-day leave Monday 2026-05-11. + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 11, 9, 0, 0), + datetime(2026, 5, 11, 17, 0, 0), + ) + # Half-day leave Friday 2026-05-15 11:00–13:00. + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 15, 11, 0, 0), + datetime(2026, 5, 15, 13, 0, 0), + full_day=False, + ) + start_datetime = datetime(2026, 5, 11, 7, 0, 0) + end_datetime = datetime(2026, 5, 15, 18, 0, 0) + + result = self.employee_two_week._get_work_hours_and_leaves( + start_datetime, end_datetime + ) + + leave_entries = [e for e in result if e.type == "leave"] + work_entries = [e for e in result if e.type == "work"] + self.assertEqual(len(leave_entries), 2) + self.assertEqual(len(work_entries), 5) + # Monday: only leave, no work. + monday_entries = [e for e in result if e.datetime_from.date().day == 11] + self.assertEqual(len(monday_entries), 1) + self.assertEqual(monday_entries[0].type, "leave") + # Friday: two work slots split around the half-day leave. + friday_entries = [e for e in result if e.datetime_from.date().day == 15] + friday_work = [e for e in friday_entries if e.type == "work"] + self.assertEqual(len(friday_work), 2) + self.assertEqual( + friday_work[0].datetime_from.timetz().replace(tzinfo=None), time(9, 0) + ) + self.assertEqual( + friday_work[0].datetime_to.timetz().replace(tzinfo=None), time(11, 0) + ) + self.assertEqual( + friday_work[1].datetime_from.timetz().replace(tzinfo=None), time(13, 0) + ) + self.assertEqual( + friday_work[1].datetime_to.timetz().replace(tzinfo=None), time(17, 0) + ) + + def test_four_day_week_with_spanning_leave(self): + """Odd week (4 days, Mon off): leave from prev Fri to Wed → 2 leave, 2 work.""" + # ISO week 21 (odd, week_type 1): Mon off, Tue–Fri working. + # Leave from Friday 2026-05-15 09:00 through Wednesday 2026-05-20 17:00. + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 15, 9, 0, 0), + datetime(2026, 5, 20, 17, 0, 0), + ) + # Period: Mon 2026-05-18 through Fri 2026-05-22. + start_datetime = datetime(2026, 5, 18, 7, 0, 0) + end_datetime = datetime(2026, 5, 22, 18, 0, 0) + + result = self.employee_two_week._get_work_hours_and_leaves( + start_datetime, end_datetime + ) + + # Monday is a day off in the odd week, so not present at all. + monday_entries = [e for e in result if e.datetime_from.date().day == 18] + self.assertEqual(len(monday_entries), 0) + leave_entries = [e for e in result if e.type == "leave"] + work_entries = [e for e in result if e.type == "work"] + self.assertEqual(len(leave_entries), 2) + self.assertEqual(len(work_entries), 2) + # Leave entries cover Tuesday and Wednesday (clipped to work hours). + self.assertEqual(leave_entries[0].datetime_from.date().day, 19) # Tue + self.assertEqual(leave_entries[1].datetime_from.date().day, 20) # Wed + # Work entries are Thursday and Friday. + self.assertEqual(work_entries[0].datetime_from.date().day, 21) # Thu + self.assertEqual(work_entries[1].datetime_from.date().day, 22) # Fri + + def test_five_day_week_with_holiday_splitting_leave(self): + """Mon-Fri week: leave Tue-Thu with holiday on Wed → work,leave,holiday,leave,work.""" + # Public holiday: Wednesday 2026-05-27. + self._make_public_holiday(date(2026, 5, 27)) + # Leave from Tuesday 09:00 through Thursday 17:00 (spans the holiday). + self._make_validated_leave( + self.employee, + datetime(2026, 5, 26, 9, 0, 0), + datetime(2026, 5, 28, 17, 0, 0), + ) + start_datetime = datetime(2026, 5, 25, 7, 0, 0) + end_datetime = datetime(2026, 5, 29, 18, 0, 0) + + result = self.employee._get_work_hours_and_leaves(start_datetime, end_datetime) + + self.assertEqual(len(result), 5) + self.assertEqual(result[0].type, "work") + self.assertEqual(result[0].datetime_from.date().day, 25) # Mon + self.assertEqual(result[1].type, "leave") + self.assertEqual(result[1].datetime_from.date().day, 26) # Tue + self.assertEqual(result[2].type, "holiday") + self.assertEqual(result[2].datetime_from.date().day, 27) # Wed + self.assertEqual(result[3].type, "leave") + self.assertEqual(result[3].datetime_from.date().day, 28) # Thu + self.assertEqual(result[4].type, "work") + self.assertEqual(result[4].datetime_from.date().day, 29) # Fri + + def test_four_day_week_with_holiday_and_partial_leave(self): + """Odd week (Mon off): holiday Thu, 4h leave Fri 09-13 → + Tue/Wed work, Thu holiday, Fri×2. + """ + # Public holiday: Thursday 2026-05-21 (week 21, odd week for two-week calendar). + self._make_public_holiday(date(2026, 5, 21)) + # Half-day leave Friday 2026-05-22 09:00–13:00. + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 22, 9, 0, 0), + datetime(2026, 5, 22, 13, 0, 0), + full_day=False, + ) + start_datetime = datetime(2026, 5, 18, 7, 0, 0) + end_datetime = datetime(2026, 5, 22, 18, 0, 0) + + result = self.employee_two_week._get_work_hours_and_leaves( + start_datetime, end_datetime + ) + + self.assertEqual(len(result), 5) + # Monday is a day off in the odd week. + monday_entries = [e for e in result if e.datetime_from.date().day == 18] + self.assertEqual(len(monday_entries), 0) + # Tuesday and Wednesday: full work days. + self.assertEqual(result[0].type, "work") + self.assertEqual(result[0].datetime_from.date().day, 19) # Tue + self.assertEqual(result[1].type, "work") + self.assertEqual(result[1].datetime_from.date().day, 20) # Wed + # Thursday: replaced by the public holiday. + self.assertEqual(result[2].type, "holiday") + self.assertEqual(result[2].datetime_from.date().day, 21) # Thu + # Friday: leave 09-13, then remaining work 13-17. + self.assertEqual(result[3].type, "leave") + self.assertEqual(result[3].datetime_from.date().day, 22) # Fri + self.assertEqual( + result[3].datetime_from.timetz().replace(tzinfo=None), time(9, 0) + ) + self.assertEqual( + result[3].datetime_to.timetz().replace(tzinfo=None), time(13, 0) + ) + self.assertEqual(result[4].type, "work") + self.assertEqual(result[4].datetime_from.date().day, 22) # Fri + self.assertEqual( + result[4].datetime_from.timetz().replace(tzinfo=None), time(13, 0) + ) + self.assertEqual( + result[4].datetime_to.timetz().replace(tzinfo=None), time(17, 0) + ) + + def test_leave_starting_before_work_schedule(self): + """Leave starting before 09:00 is clipped to work start; remaining work follows.""" + # Leave Tuesday 2026-06-02 07:00–13:00 — starts 2 h before work begins. + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 7, 0, 0), + datetime(2026, 6, 2, 13, 0, 0), + full_day=False, + ) + start_datetime = datetime(2026, 6, 1, 7, 0, 0) + end_datetime = datetime(2026, 6, 5, 18, 0, 0) + + result = self.employee._get_work_hours_and_leaves(start_datetime, end_datetime) + + tuesday = [e for e in result if e.datetime_from.date().day == 2] + self.assertEqual(len(tuesday), 2) + leave_slot = next(e for e in tuesday if e.type == "leave") + work_slot = next(e for e in tuesday if e.type == "work") + # Leave is clipped to work start. + self.assertEqual( + leave_slot.datetime_from.timetz().replace(tzinfo=None), time(9, 0) + ) + self.assertEqual( + leave_slot.datetime_to.timetz().replace(tzinfo=None), time(13, 0) + ) + # Remaining work runs from leave end to work end. + self.assertEqual( + work_slot.datetime_from.timetz().replace(tzinfo=None), time(13, 0) + ) + self.assertEqual( + work_slot.datetime_to.timetz().replace(tzinfo=None), time(17, 0) + ) + + def test_leave_ending_after_work_schedule(self): + """Leave ending after 17:00 is clipped to work end; preceding work is emitted.""" + # Leave Tuesday 2026-06-02 13:00–19:00 — ends 2 h after work finishes. + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 13, 0, 0), + datetime(2026, 6, 2, 19, 0, 0), + full_day=False, + ) + start_datetime = datetime(2026, 6, 1, 7, 0, 0) + end_datetime = datetime(2026, 6, 5, 18, 0, 0) + + result = self.employee._get_work_hours_and_leaves(start_datetime, end_datetime) + + tuesday = [e for e in result if e.datetime_from.date().day == 2] + self.assertEqual(len(tuesday), 2) + work_slot = next(e for e in tuesday if e.type == "work") + leave_slot = next(e for e in tuesday if e.type == "leave") + # Work runs from work start to leave start. + self.assertEqual( + work_slot.datetime_from.timetz().replace(tzinfo=None), time(9, 0) + ) + self.assertEqual( + work_slot.datetime_to.timetz().replace(tzinfo=None), time(13, 0) + ) + # Leave is clipped to work end. + self.assertEqual( + leave_slot.datetime_from.timetz().replace(tzinfo=None), time(13, 0) + ) + self.assertEqual( + leave_slot.datetime_to.timetz().replace(tzinfo=None), time(17, 0) + ) + + def test_two_leaves_within_one_work_slot(self): + """Two partial leaves within a single work slot produce five entries.""" + # Leave 1: 10:00–11:00, Leave 2: 13:00–14:00, both on Tuesday 2026-06-02. + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 10, 0, 0), + datetime(2026, 6, 2, 11, 0, 0), + full_day=False, + ) + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 13, 0, 0), + datetime(2026, 6, 2, 14, 0, 0), + full_day=False, + ) + result = self.employee._get_work_hours_and_leaves( + datetime(2026, 6, 2, 7, 0, 0), + datetime(2026, 6, 2, 18, 0, 0), + ) + tuesday = [e for e in result if e.datetime_from.date().day == 2] + self.assertEqual(len(tuesday), 5) + types = [e.type for e in tuesday] + self.assertEqual(types, ["work", "leave", "work", "leave", "work"]) + times = [(e.datetime_from.hour, e.datetime_to.hour) for e in tuesday] + self.assertEqual(times, [(9, 10), (10, 11), (11, 13), (13, 14), (14, 17)]) + + def test_three_leaves_within_one_work_slot(self): + """Three partial leaves within a single work slot produce seven entries.""" + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 10, 0, 0), + datetime(2026, 6, 2, 11, 0, 0), + full_day=False, + ) + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 12, 0, 0), + datetime(2026, 6, 2, 13, 0, 0), + full_day=False, + ) + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 15, 0, 0), + datetime(2026, 6, 2, 16, 0, 0), + full_day=False, + ) + result = self.employee._get_work_hours_and_leaves( + datetime(2026, 6, 2, 7, 0, 0), + datetime(2026, 6, 2, 18, 0, 0), + ) + tuesday = [e for e in result if e.datetime_from.date().day == 2] + self.assertEqual(len(tuesday), 7) + types = [e.type for e in tuesday] + self.assertEqual( + types, ["work", "leave", "work", "leave", "work", "leave", "work"] + ) + times = [(e.datetime_from.hour, e.datetime_to.hour) for e in tuesday] + self.assertEqual( + times, [(9, 10), (10, 11), (11, 12), (12, 13), (13, 15), (15, 16), (16, 17)] + ) + + def test_leave_before_work_then_second_leave_within_remaining_work(self): + """Leave starting before work then a second leave within the remaining work slot.""" + # Leave 1: 07:00–11:00 (starts before work at 09:00). + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 7, 0, 0), + datetime(2026, 6, 2, 11, 0, 0), + full_day=False, + ) + # Leave 2: 13:00–14:00 (within remaining work 11:00–17:00). + self._make_validated_leave( + self.employee, + datetime(2026, 6, 2, 13, 0, 0), + datetime(2026, 6, 2, 14, 0, 0), + full_day=False, + ) + result = self.employee._get_work_hours_and_leaves( + datetime(2026, 6, 2, 7, 0, 0), + datetime(2026, 6, 2, 18, 0, 0), + ) + tuesday = [e for e in result if e.datetime_from.date().day == 2] + self.assertEqual(len(tuesday), 4) + types = [e.type for e in tuesday] + self.assertEqual(types, ["leave", "work", "leave", "work"]) + times = [(e.datetime_from.hour, e.datetime_to.hour) for e in tuesday] + self.assertEqual(times, [(9, 11), (11, 13), (13, 14), (14, 17)]) + + def test_full_day_leave_adjusts_to_changed_work_schedule(self): + """Full-day leave is clipped to the current schedule, not the stored leave times.""" + # Register a full-day leave while the schedule is 09:00–17:00. + self._make_validated_leave( + self.employee, + datetime(2026, 5, 11, 9, 0, 0), + datetime(2026, 5, 11, 17, 0, 0), + ) + # Simulate a schedule change: shift all attendances to 08:00–16:00. + self.employee.resource_calendar_id.attendance_ids.write( + {"hour_from": 8.0, "hour_to": 16.0} + ) + + result = self.employee._get_work_hours_and_leaves( + datetime(2026, 5, 11, 7, 0, 0), + datetime(2026, 5, 11, 18, 0, 0), + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].type, "leave") + self.assertEqual( + result[0].datetime_from.timetz().replace(tzinfo=None), time(8, 0) + ) + self.assertEqual( + result[0].datetime_to.timetz().replace(tzinfo=None), time(16, 0) + ) + self.assertAlmostEqual(result[0].duration, 8.0) diff --git a/hr_holidays_work_and_leaves/tests/test_get_work_per_day.py b/hr_holidays_work_and_leaves/tests/test_get_work_per_day.py new file mode 100644 index 000000000..a4d4b5064 --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/test_get_work_per_day.py @@ -0,0 +1,57 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import date, datetime + +from .common import WorkAndLeavesBase + + +class TestGetWorkPerDay(WorkAndLeavesBase): + def test_five_work_days_wednesday_to_tuesday(self): + """_get_work_per_day yields exactly 5 entries for Wed–Tue spanning a weekend.""" + # Wednesday 2026-05-20 07:00 UTC — before work starts + start_datetime = datetime(2026, 5, 20, 7, 0, 0) + # Tuesday 2026-05-26 18:00 UTC — after work ends + end_datetime = datetime(2026, 5, 26, 18, 0, 0) + + work_days = list(self.employee._get_work_per_day(start_datetime, end_datetime)) + + self.assertEqual(len(work_days), 5) + expected_dates = [ + date(2026, 5, 20), # Wednesday + date(2026, 5, 21), # Thursday + date(2026, 5, 22), # Friday + date(2026, 5, 25), # Monday (Saturday and Sunday skipped) + date(2026, 5, 26), # Tuesday + ] + actual_dates = [entry.datetime_from.date() for entry in work_days] + self.assertEqual(actual_dates, expected_dates) + for entry in work_days: + self.assertEqual(entry.duration, 8.0) + self.assertEqual(entry.type, "work") + + def test_nine_work_days_two_week_period(self): + """Two-week calendar yields 9 entries: 5 in even week, 4 in odd week (Mon off).""" + # Monday 2026-05-11 is ISO week 20 (even → week_type 0): all 5 days work. + # Monday 2026-05-18 is ISO week 21 (odd → week_type 1): Mon off, 4 days work. + start_datetime = datetime(2026, 5, 11, 7, 0, 0) + end_datetime = datetime(2026, 5, 22, 18, 0, 0) + + work_days = list( + self.employee_two_week._get_work_per_day(start_datetime, end_datetime) + ) + + self.assertEqual(len(work_days), 9) + expected_dates = [ + date(2026, 5, 11), # Monday — even week + date(2026, 5, 12), # Tuesday — even week + date(2026, 5, 13), # Wednesday — even week + date(2026, 5, 14), # Thursday — even week + date(2026, 5, 15), # Friday — even week + # May 18 (Monday) skipped — odd week has no Monday + date(2026, 5, 19), # Tuesday — odd week + date(2026, 5, 20), # Wednesday — odd week + date(2026, 5, 21), # Thursday — odd week + date(2026, 5, 22), # Friday — odd week + ] + actual_dates = [entry.datetime_from.date() for entry in work_days] + self.assertEqual(actual_dates, expected_dates) diff --git a/hr_holidays_work_and_leaves/tests/test_hr_employee_planning.py b/hr_holidays_work_and_leaves/tests/test_hr_employee_planning.py new file mode 100644 index 000000000..29f7bab11 --- /dev/null +++ b/hr_holidays_work_and_leaves/tests/test_hr_employee_planning.py @@ -0,0 +1,193 @@ +# Copyright 2026 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import date, datetime + +from .common import WorkAndLeavesBase + + +class TestHrEmployeePlanning(WorkAndLeavesBase): + def _create_wizard(self, employee, date_start, date_end): + return self.env["hr.employee.planning"].create( + { + "employee_id": employee.id, + "date_start": date_start, + "date_end": date_end, + } + ) + + def test_action_show_planning_opens_wizard(self): + """action_show_planning creates a wizard for the employee and returns its action.""" + action = self.employee.action_show_planning() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "hr.employee.planning") + self.assertEqual(action["view_mode"], "form") + self.assertEqual(action["target"], "new") + wizard = self.env["hr.employee.planning"].browse(action["res_id"]) + self.assertEqual(wizard.employee_id, self.employee) + + def test_even_week_full_and_partial_leave(self): + """Even week: full Mon leave + Fri half-day leave → 5 lines with correct hours.""" + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 11, 9, 0, 0), + datetime(2026, 5, 11, 17, 0, 0), + ) + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 15, 11, 0, 0), + datetime(2026, 5, 15, 13, 0, 0), + full_day=False, + ) + wizard = self._create_wizard( + self.employee_two_week, date(2026, 5, 11), date(2026, 5, 15) + ) + wizard.action_compute() + + lines = wizard.planning_line_ids.sorted("date") + self.assertEqual(len(lines), 5) + # Monday: full-day leave, no work. + self.assertEqual(lines[0].date, date(2026, 5, 11)) + self.assertAlmostEqual(lines[0].hours_work, 0.0) + self.assertAlmostEqual(lines[0].hours_leave, 8.0) + self.assertAlmostEqual(lines[0].hours_holiday, 0.0) + # Tuesday through Thursday: full work days. + for idx, day in enumerate([12, 13, 14], start=1): + self.assertEqual(lines[idx].date, date(2026, 5, day)) + self.assertAlmostEqual(lines[idx].hours_work, 8.0) + self.assertAlmostEqual(lines[idx].hours_leave, 0.0) + self.assertAlmostEqual(lines[idx].hours_holiday, 0.0) + # Friday: 6h work split around the 2h half-day leave. + self.assertEqual(lines[4].date, date(2026, 5, 15)) + self.assertAlmostEqual(lines[4].hours_work, 6.0) + self.assertAlmostEqual(lines[4].hours_leave, 2.0) + self.assertAlmostEqual(lines[4].hours_holiday, 0.0) + + def test_odd_week_spanning_leave(self): + """Odd week (Mon off): leave Fri–Wed → 4 lines, Tue/Wed leave, Thu/Fri work.""" + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 15, 9, 0, 0), + datetime(2026, 5, 20, 17, 0, 0), + ) + wizard = self._create_wizard( + self.employee_two_week, date(2026, 5, 18), date(2026, 5, 22) + ) + wizard.action_compute() + + lines = wizard.planning_line_ids.sorted("date") + self.assertEqual(len(lines), 4) + # Monday absent (day off in odd week). + # Tuesday and Wednesday: full leave days. + self.assertEqual(lines[0].date, date(2026, 5, 19)) + self.assertAlmostEqual(lines[0].hours_work, 0.0) + self.assertAlmostEqual(lines[0].hours_leave, 8.0) + self.assertAlmostEqual(lines[0].hours_holiday, 0.0) + self.assertEqual(lines[1].date, date(2026, 5, 20)) + self.assertAlmostEqual(lines[1].hours_work, 0.0) + self.assertAlmostEqual(lines[1].hours_leave, 8.0) + self.assertAlmostEqual(lines[1].hours_holiday, 0.0) + # Thursday and Friday: full work days. + self.assertEqual(lines[2].date, date(2026, 5, 21)) + self.assertAlmostEqual(lines[2].hours_work, 8.0) + self.assertAlmostEqual(lines[2].hours_leave, 0.0) + self.assertAlmostEqual(lines[2].hours_holiday, 0.0) + self.assertEqual(lines[3].date, date(2026, 5, 22)) + self.assertAlmostEqual(lines[3].hours_work, 8.0) + self.assertAlmostEqual(lines[3].hours_leave, 0.0) + self.assertAlmostEqual(lines[3].hours_holiday, 0.0) + + def test_five_day_week_with_holiday_splitting_leave(self): + """Mon-Fri week: leave Tue–Thu with holiday on Wed → 5 lines.""" + self._make_public_holiday(date(2026, 5, 27)) + self._make_validated_leave( + self.employee, + datetime(2026, 5, 26, 9, 0, 0), + datetime(2026, 5, 28, 17, 0, 0), + ) + wizard = self._create_wizard( + self.employee, date(2026, 5, 25), date(2026, 5, 29) + ) + wizard.action_compute() + + lines = wizard.planning_line_ids.sorted("date") + self.assertEqual(len(lines), 5) + # Monday: work. + self.assertEqual(lines[0].date, date(2026, 5, 25)) + self.assertAlmostEqual(lines[0].hours_work, 8.0) + self.assertAlmostEqual(lines[0].hours_leave, 0.0) + self.assertAlmostEqual(lines[0].hours_holiday, 0.0) + # Tuesday: leave. + self.assertEqual(lines[1].date, date(2026, 5, 26)) + self.assertAlmostEqual(lines[1].hours_work, 0.0) + self.assertAlmostEqual(lines[1].hours_leave, 8.0) + self.assertAlmostEqual(lines[1].hours_holiday, 0.0) + # Wednesday: public holiday. + self.assertEqual(lines[2].date, date(2026, 5, 27)) + self.assertAlmostEqual(lines[2].hours_work, 0.0) + self.assertAlmostEqual(lines[2].hours_leave, 0.0) + self.assertAlmostEqual(lines[2].hours_holiday, 8.0) + # Thursday: leave. + self.assertEqual(lines[3].date, date(2026, 5, 28)) + self.assertAlmostEqual(lines[3].hours_work, 0.0) + self.assertAlmostEqual(lines[3].hours_leave, 8.0) + self.assertAlmostEqual(lines[3].hours_holiday, 0.0) + # Friday: work. + self.assertEqual(lines[4].date, date(2026, 5, 29)) + self.assertAlmostEqual(lines[4].hours_work, 8.0) + self.assertAlmostEqual(lines[4].hours_leave, 0.0) + self.assertAlmostEqual(lines[4].hours_holiday, 0.0) + + def test_four_day_week_with_holiday_and_partial_leave(self): + """Odd week (Mon off): holiday Thu, 4h leave Fri → 4 lines.""" + self._make_public_holiday(date(2026, 5, 21)) + self._make_validated_leave( + self.employee_two_week, + datetime(2026, 5, 22, 9, 0, 0), + datetime(2026, 5, 22, 13, 0, 0), + full_day=False, + ) + wizard = self._create_wizard( + self.employee_two_week, date(2026, 5, 18), date(2026, 5, 22) + ) + wizard.action_compute() + + lines = wizard.planning_line_ids.sorted("date") + self.assertEqual(len(lines), 4) + # Monday absent (day off in odd week). + # Tuesday and Wednesday: full work days. + self.assertEqual(lines[0].date, date(2026, 5, 19)) + self.assertAlmostEqual(lines[0].hours_work, 8.0) + self.assertAlmostEqual(lines[0].hours_leave, 0.0) + self.assertAlmostEqual(lines[0].hours_holiday, 0.0) + self.assertEqual(lines[1].date, date(2026, 5, 20)) + self.assertAlmostEqual(lines[1].hours_work, 8.0) + self.assertAlmostEqual(lines[1].hours_leave, 0.0) + self.assertAlmostEqual(lines[1].hours_holiday, 0.0) + # Thursday: public holiday replaces the work slot. + self.assertEqual(lines[2].date, date(2026, 5, 21)) + self.assertAlmostEqual(lines[2].hours_work, 0.0) + self.assertAlmostEqual(lines[2].hours_leave, 0.0) + self.assertAlmostEqual(lines[2].hours_holiday, 8.0) + # Friday: 4h leave followed by 4h work. + self.assertEqual(lines[3].date, date(2026, 5, 22)) + self.assertAlmostEqual(lines[3].hours_work, 4.0) + self.assertAlmostEqual(lines[3].hours_leave, 4.0) + self.assertAlmostEqual(lines[3].hours_holiday, 0.0) + + def test_timezone_mismatch_logs_warning(self): + """A warning is logged when the employee's user timezone differs from the calendar.""" + user = self.env["res.users"].create( + { + "name": "TZ Mismatch User", + "login": "tz_mismatch_user@example.com", + "tz": "Europe/Amsterdam", + } + ) + self.employee.write({"user_id": user.id}) + logger_name = "odoo.addons.hr_holidays_work_and_leaves.models.hr_employee" + with self.assertLogs(logger_name, level="WARNING"): + self.employee._get_work_hours_and_leaves( + datetime(2026, 5, 25, 7, 0, 0), + datetime(2026, 5, 29, 18, 0, 0), + ) diff --git a/hr_holidays_work_and_leaves/utils/__init__.py b/hr_holidays_work_and_leaves/utils/__init__.py new file mode 100644 index 000000000..6d4f4bde9 --- /dev/null +++ b/hr_holidays_work_and_leaves/utils/__init__.py @@ -0,0 +1 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/hr_holidays_work_and_leaves/utils/employee_day.py b/hr_holidays_work_and_leaves/utils/employee_day.py new file mode 100644 index 000000000..e37750b19 --- /dev/null +++ b/hr_holidays_work_and_leaves/utils/employee_day.py @@ -0,0 +1,123 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from dataclasses import dataclass +from datetime import datetime, time, timedelta + + +def _time_to_float(t): + """Return hours since midnight; time(0, 0) used as end-of-day sentinel = 24.0.""" + return 24.0 if t == time(0, 0) else t.hour + t.minute / 60.0 + + +@dataclass +class WorkEntry: + """A single work, leave, or holiday interval in the planning pipeline.""" + + type: str + datetime_from: datetime + datetime_to: datetime + holiday_name: str = None + holiday_status_id: object = None + name: str = None + + @property + def duration(self): + return (self.datetime_to - self.datetime_from).total_seconds() / 3600 + + +class TimeSlot: + """A single work or leave slot within a day.""" + + __slots__ = ("end_time", "hours_overlap_work", "name", "start_time", "type") + + def __init__(self, start_time, end_time, slot_type, name=None): + self.start_time = start_time + self.end_time = end_time + self.type = slot_type + self.hours_overlap_work = 0.0 + self.name = name + + +class EmployeeDay: + """Work and leave summary for a single employee on a single day.""" + + __slots__ = ( + "date", + "day_schedule", + "hours_appointment", + "hours_holiday", + "hours_leave", + "hours_leave_requested", + "hours_work", + ) + + def __init__(self, date): + self.date = date + self.hours_work = 0.0 + self.hours_leave = 0.0 + self.hours_holiday = 0.0 + self.hours_leave_requested = 0.0 + self.hours_appointment = 0.0 + self.day_schedule = [] + + def insert_slot(self, slot): + """Insert slot into day_schedule, maintaining order by start_time.""" + for idx, ts in enumerate(self.day_schedule): + if ts.start_time > slot.start_time: + self.day_schedule.insert(idx, slot) + return + self.day_schedule.append(slot) + + def compute_slot_work_overlap(self, slot_from_time, slot_to_time): + """Return total hours that [slot_from_time, slot_to_time) overlaps work slots.""" + a = 0.0 if slot_from_time == time(0, 0) else _time_to_float(slot_from_time) + b = _time_to_float(slot_to_time) + total = 0.0 + for ts in self.day_schedule: + if ts.type != "work": + continue + c = ts.start_time.hour + ts.start_time.minute / 60.0 + d = _time_to_float(ts.end_time) + total += max(0.0, min(b, d) - max(a, c)) + return total + + +class EmployeeDaySchedule: + """Ordered collection of EmployeeDay objects, indexed by date for fast lookup.""" + + def __init__(self, days): + self._days = days + self._days_by_date = {day.date: day for day in days} + + def __iter__(self): + return iter(self._days) + + def add_to_days(self, date_from, date_to, slot_type, name): + """Insert a leave or appointment slot into every day it overlaps with work.""" + end_date = ( + date_to.date() - timedelta(days=1) + if date_to.time() == time(0, 0) + else date_to.date() + ) + current_date = date_from.date() + while current_date <= end_date: + day_entry = self._days_by_date.get(current_date) + if day_entry is not None: + slot_from_time = ( + date_from.time() if current_date == date_from.date() else time(0, 0) + ) + slot_to_time = ( + date_to.time() if current_date == date_to.date() else time(0, 0) + ) + overlap = day_entry.compute_slot_work_overlap( + slot_from_time, slot_to_time + ) + if overlap > 0.0: + slot = TimeSlot(slot_from_time, slot_to_time, slot_type, name=name) + slot.hours_overlap_work = overlap + day_entry.insert_slot(slot) + if slot_type == "leave_requested": + day_entry.hours_leave_requested += overlap + elif slot_type == "appointment": + day_entry.hours_appointment += overlap + current_date += timedelta(days=1) diff --git a/hr_holidays_work_and_leaves/utils/peekable_iterator.py b/hr_holidays_work_and_leaves/utils/peekable_iterator.py new file mode 100644 index 000000000..d38fc7bb5 --- /dev/null +++ b/hr_holidays_work_and_leaves/utils/peekable_iterator.py @@ -0,0 +1,26 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +class PeekableIterator: + """Wraps a generator to provide hasNext()/next() peek semantics.""" + + def __init__(self, iterable): + self._iter = iter(iterable) + self._exhausted = False + self._peeked = None + self._advance() + + def _advance(self): + try: + self._peeked = next(self._iter) + except StopIteration: + self._exhausted = True + + def hasNext(self): + return not self._exhausted + + def next(self): + value = self._peeked + self._advance() + return value diff --git a/hr_holidays_work_and_leaves/views/hr_employee_views.xml b/hr_holidays_work_and_leaves/views/hr_employee_views.xml new file mode 100644 index 000000000..29d6d8afa --- /dev/null +++ b/hr_holidays_work_and_leaves/views/hr_employee_views.xml @@ -0,0 +1,19 @@ + + + + hr.employee.form.planning.button + hr.employee + + + +