feat(fusion_clock): bi-weekly attendance filter — pay-period filters + picker

Reuse the existing Pay Period setting (Frequency + Anchor) as the single
source of truth via a shared pure helper (models/pay_period.py); fusion.clock.report
delegates to it. Add Current/Previous/Next Pay Period filters to the attendance
search view (search-method computed booleans on hr.attendance), a Bi-Weekly Period
picker wizard (pick start -> auto +2 weeks, editable; Apply opens the filtered list)
reachable from an Attendance menu item and a dashboard tile. Window follows the
configured frequency; TZ-correct via local-day boundaries. Bump 3.14.4 -> 3.15.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 11:20:06 -04:00
parent e230e42d81
commit 3f78f652e7
15 changed files with 376 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import pay_period
from . import clock_location
from . import hr_attendance
from . import hr_employee

View File

@@ -455,53 +455,13 @@ class FusionClockReport(models.Model):
@api.model
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
"""Calculate the period start/end dates based on schedule type."""
from dateutil.relativedelta import relativedelta
import datetime
"""Calculate the period start/end dates based on schedule type.
if period_start_str:
try:
anchor = fields.Date.from_string(period_start_str)
except Exception:
anchor = reference_date.replace(day=1)
else:
anchor = reference_date.replace(day=1)
if schedule_type == 'weekly':
days_diff = (reference_date - anchor).days
period_num = days_diff // 7
period_start = anchor + timedelta(days=period_num * 7)
period_end = period_start + timedelta(days=6)
elif schedule_type == 'biweekly':
days_diff = (reference_date - anchor).days
period_num = days_diff // 14
period_start = anchor + timedelta(days=period_num * 14)
period_end = period_start + timedelta(days=13)
elif schedule_type == 'semi_monthly':
if reference_date.day <= 15:
period_start = reference_date.replace(day=1)
period_end = reference_date.replace(day=15)
else:
period_start = reference_date.replace(day=16)
# Last day of month
next_month = reference_date.replace(day=28) + timedelta(days=4)
period_end = next_month - timedelta(days=next_month.day)
elif schedule_type == 'monthly':
period_start = reference_date.replace(day=1)
next_month = reference_date.replace(day=28) + timedelta(days=4)
period_end = next_month - timedelta(days=next_month.day)
else:
# Default biweekly
days_diff = (reference_date - anchor).days
period_num = days_diff // 14
period_start = anchor + timedelta(days=period_num * 14)
period_end = period_start + timedelta(days=13)
return period_start, period_end
Delegates to the shared pure helper so reports, the attendance period
filters and the Bi-Weekly Period picker all use one implementation.
"""
from .pay_period import compute_pay_period
return compute_pay_period(schedule_type, period_start_str, reference_date)
@api.model
def action_generate_historical_reports(self):

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from odoo import models, fields, api
from odoo.tools import float_round
from .tz_utils import get_local_today, get_local_day_boundaries
from .pay_period import current_prev_next
_logger = logging.getLogger(__name__)
@@ -207,6 +208,56 @@ class HrAttendance(models.Model):
help="Selfie captured at clock-in for verification.",
)
# Pay-period filters (display-only flags; the filtering is done by the
# search methods, which compute the window from the configured frequency +
# anchor — see models/pay_period.py).
x_fclk_in_current_period = fields.Boolean(
string='In Current Pay Period',
compute='_compute_fclk_period_flags', search='_search_fclk_in_current_period')
x_fclk_in_previous_period = fields.Boolean(
string='In Previous Pay Period',
compute='_compute_fclk_period_flags', search='_search_fclk_in_previous_period')
x_fclk_in_next_period = fields.Boolean(
string='In Next Pay Period',
compute='_compute_fclk_period_flags', search='_search_fclk_in_next_period')
def _compute_fclk_period_flags(self):
# Display-only; filtering happens entirely in the search methods.
for att in self:
att.x_fclk_in_current_period = False
att.x_fclk_in_previous_period = False
att.x_fclk_in_next_period = False
def _fclk_period_domain(self, which):
"""check_in domain for the named pay-period window ('current' /
'previous' / 'next'), computed from the configured frequency + anchor."""
ICP = self.env['ir.config_parameter'].sudo()
frequency = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
anchor = ICP.get_param('fusion_clock.pay_period_start', '')
start, end = current_prev_next(frequency, anchor, get_local_today(self.env))[which]
start_utc, _dummy = get_local_day_boundaries(self.env, start)
_dummy2, end_excl_utc = get_local_day_boundaries(self.env, end)
return ['&',
('check_in', '>=', fields.Datetime.to_string(start_utc)),
('check_in', '<', fields.Datetime.to_string(end_excl_utc))]
def _fclk_period_search(self, which, operator, value):
"""Resolve the filter to a check_in domain. The shipped filters emit
('=', True); handle '='/'!='+bool generally so the public field never
silently returns the wrong set under negation."""
domain = self._fclk_period_domain(which)
positive = (operator == '=') == bool(value)
return domain if positive else ['!'] + domain
def _search_fclk_in_current_period(self, operator, value):
return self._fclk_period_search('current', operator, value)
def _search_fclk_in_previous_period(self, operator, value):
return self._fclk_period_search('previous', operator, value)
def _search_fclk_in_next_period(self, operator, value):
return self._fclk_period_search('next', operator, value)
@api.depends('worked_hours', 'x_fclk_break_minutes')
def _compute_net_hours(self):
for att in self:

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Pay-period date math shared by reports, attendance filters and the period
picker. Pure functions (no ORM) so they unit-test trivially and never drift
between callers."""
from datetime import date, timedelta
def period_length_days(frequency):
"""Fixed window length for grid frequencies; None for calendar-based ones."""
return {'weekly': 7, 'biweekly': 14}.get(frequency)
def compute_pay_period(frequency, anchor_str, reference_date):
"""Return (start_date, end_date) for the period containing reference_date.
``anchor_str`` is a 'YYYY-MM-DD' string or falsy (falls back to
first-of-month). Mirrors the original
fusion.clock.report._calculate_current_period logic, including floor
division so dates before the anchor resolve to the correct earlier period.
"""
if anchor_str:
try:
# Truncate to 'YYYY-MM-DD' first, matching Odoo's fields.Date.from_string
# (Date.to_date), so a stored datetime-ish anchor like
# "2026-05-04 00:00:00" still parses instead of silently falling back.
anchor = date.fromisoformat(anchor_str[:10])
except (ValueError, TypeError):
anchor = reference_date.replace(day=1)
else:
anchor = reference_date.replace(day=1)
if frequency == 'weekly':
period_num = (reference_date - anchor).days // 7
start = anchor + timedelta(days=period_num * 7)
end = start + timedelta(days=6)
elif frequency == 'semi_monthly':
if reference_date.day <= 15:
start = reference_date.replace(day=1)
end = reference_date.replace(day=15)
else:
start = reference_date.replace(day=16)
next_month = reference_date.replace(day=28) + timedelta(days=4)
end = next_month - timedelta(days=next_month.day)
elif frequency == 'monthly':
start = reference_date.replace(day=1)
next_month = reference_date.replace(day=28) + timedelta(days=4)
end = next_month - timedelta(days=next_month.day)
else: # 'biweekly' and default
period_num = (reference_date - anchor).days // 14
start = anchor + timedelta(days=period_num * 14)
end = start + timedelta(days=13)
return start, end
def current_prev_next(frequency, anchor_str, today):
"""Return {'current','previous','next'} (start,end) windows. Previous/next
are derived by stepping the reference date one day outside the current
window, which works for grid AND calendar frequencies."""
cur = compute_pay_period(frequency, anchor_str, today)
prev = compute_pay_period(frequency, anchor_str, cur[0] - timedelta(days=1))
nxt = compute_pay_period(frequency, anchor_str, cur[1] + timedelta(days=1))
return {'current': cur, 'previous': prev, 'next': nxt}