Skip to content

Commit 7f7ca3c

Browse files
yava-odoomepe-odoo
andcommitted
[FIX] l10n_in_hr_payroll: fixed the sandwich leave duration
Before this commit, if you created a leave of a type that had sandwich leave enabled on a non-working day, it would show a negative duration. This commit fixes some issues related to sandwich leave cases (where Saturday and Sunday are considered non-working days): - Friday - Monday across weekend - counted (4 days). - Hour-based leave types: weekend bridging increases hours accordingly - Public holiday in the middle (Tue-Thu with Wed PH) - counted (3 days). - Stop/Start exactly on a public holiday (Tue-Wed(Public holiday), or Wed(Public holiday)-Thu) - trimmed to 1 day. - Public holiday only - 0 days. - Two single-day leaves around a Public holiday - When the second leave is created, it bridges via a public holiday (2 days), - The first one remains 1 day if it stands alone - Mixed leave types: - If the linked leave type doesn’t have sandwich enabled, no sandwich rules. - If both leave enable sandwich (with different types) - sandwich rule applies - Refusing/canceling a linked leave must immediately adjust the other side’s duration (e.g., Monday refused - Friday drops from 3 - 1 day) Task-4430044 closes odoo#233817 X-original-commit: f42f235 Signed-off-by: Yannick Tivisse (yti) <[email protected]> Signed-off-by: Yash Vaishnav (yava) <[email protected]> Co-authored-by: mepe-odoo <[email protected]>
1 parent 1f9c94e commit 7f7ca3c

File tree

2 files changed

+685
-101
lines changed

2 files changed

+685
-101
lines changed
Lines changed: 238 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
11
# Part of Odoo. See LICENSE file for full copyright and licensing details.
22

33
from datetime import datetime, timedelta, time
4+
import pytz
45

56
from odoo import api, fields, models
67
from odoo.exceptions import ValidationError
78
from odoo.tools.date_utils import sum_intervals
89
from odoo.tools.intervals import Intervals
10+
from odoo.tools.float_utils import float_compare
911

1012

1113
class HrLeave(models.Model):
1214
_inherit = "hr.leave"
1315

1416
l10n_in_contains_sandwich_leaves = fields.Boolean()
1517

18+
def _l10n_in_get_default_leave_hours(self):
19+
self.ensure_one()
20+
calendar = self.employee_id.resource_calendar_id or self.env.company.resource_calendar_id
21+
if not calendar:
22+
return 0.0
23+
start_dt = self._to_utc(self.request_date_from, 0.0, self.employee_id)
24+
end_dt = self._to_utc(self.request_date_to, 24.0, self.employee_id)
25+
work_data = self.employee_id._get_work_days_data_batch(
26+
start_dt,
27+
end_dt,
28+
compute_leaves=False,
29+
calendar=calendar,
30+
)
31+
data = work_data.get(self.employee_id.id)
32+
return data.get("hours", 0.0) if data else 0.0
33+
34+
def _l10n_in_is_full_day_request(self, hours=None, default_hours=None):
35+
self.ensure_one()
36+
default_hours = default_hours or self._l10n_in_get_default_leave_hours()
37+
hours = hours if hours is not None else (self.number_of_hours or 0.0)
38+
if not self.request_unit_hours or not default_hours:
39+
return True
40+
return float_compare(hours, default_hours, precision_digits=2) >= 0
41+
1642
@api.constrains("holiday_status_id", "request_date_from", "request_date_to")
1743
def _l10n_in_check_optional_holiday_request_dates(self):
1844
leaves_to_check = self.filtered(lambda leave: leave.holiday_status_id.l10n_in_is_limited_to_optional_days)
@@ -47,67 +73,231 @@ def _l10n_in_check_optional_holiday_request_dates(self):
4773
self.env._("The following leaves are not on Optional Holidays:\n - %s", "\n - ".join(invalid_leaves))
4874
)
4975

50-
def _l10n_in_apply_sandwich_rule(self, public_holidays, employee_leaves):
51-
self.ensure_one()
52-
if not self.request_date_from or not self.request_date_to:
53-
return
54-
date_from = self.request_date_from
55-
date_to = self.request_date_to
56-
total_leaves = (self.request_date_to - self.request_date_from).days + (0.5 if self.request_unit_half else 1)
76+
def _l10n_in_is_working(self, on_date, public_holiday_dates, resource_calendar):
77+
return on_date not in public_holiday_dates and resource_calendar._works_on_date(on_date)
5778

58-
def is_non_working_day(calendar, date):
59-
return not calendar._works_on_date(date) or any(
60-
datetime.date(holiday['date_from']) <= date <= datetime.date(holiday['date_to']) for holiday in public_holidays
61-
)
79+
def _l10n_in_count_adjacent_non_working(self, start_date, public_holiday_dates, resource_calendar, reverse=False, include_start=False):
80+
step = -1 if reverse else 1
81+
current = start_date if include_start else start_date + timedelta(days=step)
82+
count = 0
83+
while not self._l10n_in_is_working(current, public_holiday_dates, resource_calendar) and count < 30:
84+
count += 1
85+
current += timedelta(days=step)
86+
return count
6287

63-
def count_sandwich_days(calendar, date, direction):
64-
current_date = date + timedelta(days=direction)
65-
days_count = 0
66-
while is_non_working_day(calendar, current_date):
67-
days_count += 1
68-
current_date += timedelta(days=direction)
69-
for leave in employee_leaves:
70-
if leave['request_date_from'] <= current_date <= leave['request_date_to']:
71-
return days_count
72-
return 0
88+
def _l10n_in_find_linked_leave(self, start_date, public_holiday_dates, resource_calendar, leaves_by_date, reverse=False):
89+
step = -1 if reverse else 1
90+
current_date = start_date
91+
for _ in range(30):
92+
current_date += timedelta(days=step)
93+
if self._l10n_in_is_working(current_date, public_holiday_dates, resource_calendar):
94+
break
95+
linked_leave = leaves_by_date.get(current_date, self.env["hr.leave"])
96+
if linked_leave and linked_leave.request_unit_half:
97+
return self.env["hr.leave"]
98+
return linked_leave
7399

74-
calendar = self.resource_calendar_id
75-
total_leaves += count_sandwich_days(calendar, date_from, -1) + count_sandwich_days(calendar, date_to, 1)
76-
while is_non_working_day(calendar, date_from):
77-
total_leaves -= 1
78-
date_from += timedelta(days=1)
79-
while is_non_working_day(calendar, date_to):
80-
total_leaves -= 1
81-
date_to -= timedelta(days=1)
82-
return total_leaves
100+
def _l10n_in_get_linked_leaves(self, leaves_dates_by_employee, public_holidays_date_by_company):
101+
linked_before = self.env["hr.leave"]
102+
linked_after = self.env["hr.leave"]
83103

84-
def _get_durations(self, check_leave_type=True, resource_calendar=None):
85-
result = super()._get_durations(check_leave_type, resource_calendar)
86-
indian_leaves = self.filtered(lambda c: c.company_id.country_id.code == 'IN')
104+
for leave in self:
105+
public_holiday_dates = public_holidays_date_by_company.get(leave.company_id, {})
106+
leaves_by_date = leaves_dates_by_employee.get(leave.employee_id, {})
107+
linked_before |= self._l10n_in_find_linked_leave(
108+
leave.request_date_from, public_holiday_dates, leave.resource_calendar_id, leaves_by_date, reverse=True
109+
)
110+
linked_after |= self._l10n_in_find_linked_leave(
111+
leave.request_date_to, public_holiday_dates, leave.resource_calendar_id, leaves_by_date, reverse=False
112+
)
113+
return linked_before, linked_after
114+
115+
def _l10n_in_prepare_sandwich_context(self):
116+
"""
117+
Build and return a tuple:
118+
(indian_leaves, leaves_by_employee, public_holidays_by_company_id)
119+
- Filters Indian, full-day, sandwich-enabled leaves.
120+
- Prepares dicts for sibling employee leaves and company public holidays.
121+
"""
122+
indian_leaves = self.filtered(
123+
lambda leave: leave.company_id.country_id.code == "IN"
124+
and leave.holiday_status_id.l10n_in_is_sandwich_leave
125+
and not leave.request_unit_half
126+
)
87127
if not indian_leaves:
88-
return result
128+
return (indian_leaves, {}, {})
89129

90-
public_holidays = self.env['resource.calendar.leaves'].search([
91-
('resource_id', '=', False),
92-
('company_id', 'in', indian_leaves.company_id.ids),
93-
])
94-
leaves_by_employee = dict(self._read_group(
130+
leaves_dates_by_employee = {}
131+
grouped_leaves = self._read_group(
95132
domain=[
96133
('id', 'not in', self.ids),
97134
('employee_id', 'in', self.employee_id.ids),
98135
('state', 'not in', ['cancel', 'refuse']),
99-
('leave_type_request_unit', '=', 'day'),
136+
('request_unit_half', '=', False),
137+
('holiday_status_id.l10n_in_is_sandwich_leave', '=', True),
100138
],
101139
groupby=['employee_id'],
102140
aggregates=['id:recordset'],
103-
))
141+
)
142+
for emp_id, recs in grouped_leaves:
143+
valid_recs = recs.filtered(lambda leave: leave._l10n_in_is_full_day_request())
144+
if not valid_recs:
145+
continue
146+
leaves_dates_by_employee[emp_id] = {
147+
(leave.request_date_from + timedelta(days=offset)): leave
148+
for leave in valid_recs
149+
for offset in range((leave.request_date_to - leave.request_date_from).days + 1)
150+
}
151+
152+
tz = pytz.timezone(self.env.context.get("tz") or self.env.user.tz or "UTC")
153+
public_holidays_dates_by_company = {
154+
company_id: {
155+
(datetime.date(holiday.date_from.astimezone(tz)) + timedelta(days=offset)): holiday
156+
for holiday in recs
157+
for offset in range((holiday.date_to.date() - holiday.date_from.date()).days + 1)
158+
}
159+
for company_id, recs in self.env['resource.calendar.leaves']._read_group(
160+
domain=[
161+
('resource_id', '=', False),
162+
('company_id', 'in', indian_leaves.company_id.ids),
163+
],
164+
groupby=['company_id'],
165+
aggregates=['id:recordset'],
166+
)
167+
}
168+
169+
return indian_leaves, leaves_dates_by_employee, public_holidays_dates_by_company
170+
171+
def _l10n_in_apply_sandwich_rule(self, public_holidays_date_by_company, leaves_dates_by_employee):
172+
self.ensure_one()
173+
if not (self.request_date_from and self.request_date_to):
174+
return 0
175+
176+
date_from = self.request_date_from
177+
date_to = self.request_date_to
178+
public_holiday_dates = public_holidays_date_by_company.get(self.company_id, {})
179+
is_non_working_from = not self._l10n_in_is_working(date_from, public_holiday_dates, self.resource_calendar_id)
180+
is_non_working_to = not self._l10n_in_is_working(date_to, public_holiday_dates, self.resource_calendar_id)
181+
182+
if is_non_working_from and is_non_working_to and not any(
183+
self._l10n_in_is_working(date_from + timedelta(days=x), public_holiday_dates, self.resource_calendar_id)
184+
for x in range(1, (date_to - date_from).days)
185+
):
186+
return 0
187+
188+
total_leaves = (date_to - date_from).days + 1
189+
linked_before, linked_after = self._l10n_in_get_linked_leaves(leaves_dates_by_employee, public_holidays_date_by_company)
190+
linked_before_leave = linked_before[:1]
191+
linked_after_leave = linked_after[:1]
192+
# Only expand the current leave when the linked record starts before it.
193+
has_previous_link = bool(linked_before_leave and linked_before_leave.request_date_from < date_from)
194+
has_next_link = bool(linked_after_leave and linked_after_leave.request_date_from > date_to)
195+
196+
if has_previous_link:
197+
total_leaves += self._l10n_in_count_adjacent_non_working(
198+
date_from, public_holiday_dates, self.resource_calendar_id, reverse=True
199+
)
200+
elif is_non_working_from:
201+
total_leaves -= self._l10n_in_count_adjacent_non_working(
202+
date_from, public_holiday_dates, self.resource_calendar_id, include_start=True,
203+
)
204+
205+
if has_next_link:
206+
total_leaves += self._l10n_in_count_adjacent_non_working(
207+
date_to, public_holiday_dates, self.resource_calendar_id
208+
)
209+
elif is_non_working_to:
210+
total_leaves = total_leaves - self._l10n_in_count_adjacent_non_working(
211+
date_to, public_holiday_dates, self.resource_calendar_id, reverse=True, include_start=True,
212+
)
213+
return total_leaves
214+
215+
def _get_durations(self, check_leave_type=True, resource_calendar=None):
216+
result = super()._get_durations(check_leave_type, resource_calendar)
217+
218+
indian_leaves, leaves_dates_by_employee, public_holidays_date_by_company = self._l10n_in_prepare_sandwich_context()
219+
if not indian_leaves:
220+
self.l10n_in_contains_sandwich_leaves = False
221+
return result
222+
104223
for leave in indian_leaves:
105-
if leave.holiday_status_id.l10n_in_is_sandwich_leave:
106-
days, hours = result[leave.id]
107-
updated_days = leave._l10n_in_apply_sandwich_rule(public_holidays, leaves_by_employee.get(leave.employee_id, []))
108-
result[leave.id] = (updated_days, hours)
109-
if updated_days and leave.state not in ['validate', 'validate1']:
110-
leave.l10n_in_contains_sandwich_leaves = updated_days != days
111-
elif leave.state not in ['validate', 'validate1']:
224+
leave_days, hours = result[leave.id]
225+
if not leave_days or (
226+
leave.state in ["validate", "validate1"]
227+
and not self.env.user.has_group("hr_holidays.group_hr_holidays_user")
228+
):
229+
continue
230+
default_hours = leave._l10n_in_get_default_leave_hours()
231+
if not leave._l10n_in_is_full_day_request(hours=hours, default_hours=default_hours):
232+
leave.l10n_in_contains_sandwich_leaves = False
233+
continue
234+
updated_days = leave._l10n_in_apply_sandwich_rule(public_holidays_date_by_company, leaves_dates_by_employee)
235+
if updated_days and updated_days != leave_days:
236+
updated_hours = (updated_days * (hours / leave_days)) if leave_days else hours
237+
result[leave.id] = (updated_days, updated_hours)
238+
leave.l10n_in_contains_sandwich_leaves = True
239+
else:
112240
leave.l10n_in_contains_sandwich_leaves = False
113241
return result
242+
243+
def _l10n_in_update_neighbors_duration_after_change(self):
244+
indian_leaves, leaves_dates_by_employee, public_holidays_dates_by_company = self._l10n_in_prepare_sandwich_context()
245+
if not indian_leaves:
246+
return
247+
self.l10n_in_contains_sandwich_leaves = False
248+
249+
linked_before, linked_after = indian_leaves._l10n_in_get_linked_leaves(
250+
leaves_dates_by_employee, public_holidays_dates_by_company
251+
)
252+
neighbors = (linked_before | linked_after) - self
253+
if not neighbors:
254+
return
255+
256+
# Recompute neighbor durations with the baseline (non-sandwich) logic.
257+
base_map = super(HrLeave, neighbors)._get_durations(
258+
check_leave_type=True,
259+
resource_calendar=None,
260+
)
261+
262+
for neighbor in neighbors:
263+
base_days, base_hours = base_map.get(neighbor.id, (neighbor.number_of_days, neighbor.number_of_hours))
264+
default_hours = neighbor._l10n_in_get_default_leave_hours()
265+
if not neighbor._l10n_in_is_full_day_request(hours=base_hours, default_hours=default_hours):
266+
neighbor.write({
267+
'number_of_days': base_days,
268+
'number_of_hours': base_hours,
269+
'l10n_in_contains_sandwich_leaves': False,
270+
})
271+
continue
272+
updated_days = neighbor._l10n_in_apply_sandwich_rule(
273+
public_holidays_date_by_company=public_holidays_dates_by_company,
274+
leaves_dates_by_employee=leaves_dates_by_employee,
275+
)
276+
if updated_days and updated_days != base_days:
277+
new_hours = (updated_days * (base_hours / base_days)) if base_days else base_hours
278+
neighbor.write({
279+
'number_of_days': updated_days,
280+
'number_of_hours': new_hours,
281+
'l10n_in_contains_sandwich_leaves': True,
282+
})
283+
else:
284+
neighbor.write({
285+
'number_of_days': base_days,
286+
'number_of_hours': base_hours,
287+
'l10n_in_contains_sandwich_leaves': False,
288+
})
289+
290+
@api.ondelete(at_uninstall=False)
291+
def _ondelete_refresh_neighbors(self):
292+
"""Pre-delete hook: update neighbors as if these records were already deleted"""
293+
self._l10n_in_update_neighbors_duration_after_change()
294+
295+
def action_refuse(self):
296+
res = super().action_refuse()
297+
self._l10n_in_update_neighbors_duration_after_change()
298+
return res
299+
300+
def _action_user_cancel(self, reason):
301+
res = super()._action_user_cancel(reason)
302+
self._l10n_in_update_neighbors_duration_after_change()
303+
return res

0 commit comments

Comments
 (0)