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:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.14.4',
|
'version': '19.0.3.15.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -73,6 +73,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'views/clock_schedule_views.xml',
|
'views/clock_schedule_views.xml',
|
||||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||||
'wizard/clock_nfc_enrollment_views.xml',
|
'wizard/clock_nfc_enrollment_views.xml',
|
||||||
|
'wizard/clock_period_picker_views.xml',
|
||||||
'views/clock_menus.xml',
|
'views/clock_menus.xml',
|
||||||
# Views - Portal
|
# Views - Portal
|
||||||
'views/portal_clock_templates.xml',
|
'views/portal_clock_templates.xml',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import pay_period
|
||||||
from . import clock_location
|
from . import clock_location
|
||||||
from . import hr_attendance
|
from . import hr_attendance
|
||||||
from . import hr_employee
|
from . import hr_employee
|
||||||
|
|||||||
@@ -455,53 +455,13 @@ class FusionClockReport(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
|
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
|
||||||
"""Calculate the period start/end dates based on schedule type."""
|
"""Calculate the period start/end dates based on schedule type.
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
if period_start_str:
|
Delegates to the shared pure helper so reports, the attendance period
|
||||||
try:
|
filters and the Bi-Weekly Period picker all use one implementation.
|
||||||
anchor = fields.Date.from_string(period_start_str)
|
"""
|
||||||
except Exception:
|
from .pay_period import compute_pay_period
|
||||||
anchor = reference_date.replace(day=1)
|
return compute_pay_period(schedule_type, period_start_str, reference_date)
|
||||||
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
|
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def action_generate_historical_reports(self):
|
def action_generate_historical_reports(self):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
|
|||||||
from odoo import models, fields, api
|
from odoo import models, fields, api
|
||||||
from odoo.tools import float_round
|
from odoo.tools import float_round
|
||||||
from .tz_utils import get_local_today, get_local_day_boundaries
|
from .tz_utils import get_local_today, get_local_day_boundaries
|
||||||
|
from .pay_period import current_prev_next
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -207,6 +208,56 @@ class HrAttendance(models.Model):
|
|||||||
help="Selfie captured at clock-in for verification.",
|
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')
|
@api.depends('worked_hours', 'x_fclk_break_minutes')
|
||||||
def _compute_net_hours(self):
|
def _compute_net_hours(self):
|
||||||
for att in self:
|
for att in self:
|
||||||
|
|||||||
65
fusion_clock/models/pay_period.py
Normal file
65
fusion_clock/models/pay_period.py
Normal 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}
|
||||||
@@ -87,6 +87,7 @@ export class FusionClockDashboard extends Component {
|
|||||||
onViewActivityLogs() { this.action.doAction("fusion_clock.action_fusion_clock_activity_log"); }
|
onViewActivityLogs() { this.action.doAction("fusion_clock.action_fusion_clock_activity_log"); }
|
||||||
onViewPenalties() { this.action.doAction("fusion_clock.action_fusion_clock_penalty"); }
|
onViewPenalties() { this.action.doAction("fusion_clock.action_fusion_clock_penalty"); }
|
||||||
onViewShiftPlanner() { this.action.doAction("fusion_clock.action_fusion_clock_shift_planner"); }
|
onViewShiftPlanner() { this.action.doAction("fusion_clock.action_fusion_clock_shift_planner"); }
|
||||||
|
onViewBiweekly() { this.action.doAction("fusion_clock.action_fusion_clock_period_picker"); }
|
||||||
onViewReports() { this.action.doAction("fusion_clock.action_fusion_clock_report"); }
|
onViewReports() { this.action.doAction("fusion_clock.action_fusion_clock_report"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
<span class="fclk-dash-act" t-on-click="onViewCorrections">📨 Approvals</span>
|
<span class="fclk-dash-act" t-on-click="onViewCorrections">📨 Approvals</span>
|
||||||
<span class="fclk-dash-act" t-on-click="onViewPenalties">⚠ Penalties</span>
|
<span class="fclk-dash-act" t-on-click="onViewPenalties">⚠ Penalties</span>
|
||||||
<span class="fclk-dash-act" t-on-click="onViewActivityLogs">🗒 Activity Logs</span>
|
<span class="fclk-dash-act" t-on-click="onViewActivityLogs">🗒 Activity Logs</span>
|
||||||
|
<span class="fclk-dash-act" t-on-click="onViewBiweekly">🗓 Bi-Weekly Period</span>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="state.role === 'manager'">
|
<t t-if="state.role === 'manager'">
|
||||||
<span class="fclk-dash-act" t-on-click="onViewShiftPlanner">📅 Shift Planner</span>
|
<span class="fclk-dash-act" t-on-click="onViewShiftPlanner">📅 Shift Planner</span>
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ from . import test_shift_planner
|
|||||||
from . import test_photo_retention
|
from . import test_photo_retention
|
||||||
from . import test_schedule_driven
|
from . import test_schedule_driven
|
||||||
from . import test_dashboard
|
from . import test_dashboard
|
||||||
|
from . import test_pay_period
|
||||||
|
|||||||
133
fusion_clock/tests/test_pay_period.py
Normal file
133
fusion_clock/tests/test_pay_period.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from odoo import fields
|
||||||
|
from odoo.tests import tagged, TransactionCase
|
||||||
|
from odoo.addons.fusion_clock.models.pay_period import (
|
||||||
|
compute_pay_period, period_length_days, current_prev_next,
|
||||||
|
)
|
||||||
|
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestPayPeriodMath(TransactionCase):
|
||||||
|
|
||||||
|
def test_biweekly_window_is_14_days(self):
|
||||||
|
# anchor Mon 2026-05-04; a date inside the 2nd period
|
||||||
|
s, e = compute_pay_period('biweekly', '2026-05-04', date(2026, 5, 20))
|
||||||
|
self.assertEqual(s, date(2026, 5, 18))
|
||||||
|
self.assertEqual(e, date(2026, 5, 31))
|
||||||
|
self.assertEqual((e - s).days, 13)
|
||||||
|
|
||||||
|
def test_weekly_window_is_7_days(self):
|
||||||
|
s, e = compute_pay_period('weekly', '2026-05-04', date(2026, 5, 20))
|
||||||
|
self.assertEqual(s, date(2026, 5, 18))
|
||||||
|
self.assertEqual(e, date(2026, 5, 24))
|
||||||
|
|
||||||
|
def test_datetime_string_anchor_still_parses(self):
|
||||||
|
# Anchor is a free-text Char; a stored "YYYY-MM-DD hh:mm:ss" must resolve
|
||||||
|
# the same as the bare date (parity with Odoo's fields.Date.from_string).
|
||||||
|
s, e = compute_pay_period('biweekly', '2026-05-04 00:00:00', date(2026, 5, 20))
|
||||||
|
self.assertEqual((s, e), (date(2026, 5, 18), date(2026, 5, 31)))
|
||||||
|
|
||||||
|
def test_reference_before_anchor(self):
|
||||||
|
# 2026-04-25 is one biweekly period BEFORE the anchor
|
||||||
|
s, e = compute_pay_period('biweekly', '2026-05-04', date(2026, 4, 25))
|
||||||
|
self.assertEqual(s, date(2026, 4, 20))
|
||||||
|
self.assertEqual(e, date(2026, 5, 3))
|
||||||
|
|
||||||
|
def test_monthly_window(self):
|
||||||
|
s, e = compute_pay_period('monthly', '', date(2026, 2, 10))
|
||||||
|
self.assertEqual(s, date(2026, 2, 1))
|
||||||
|
self.assertEqual(e, date(2026, 2, 28))
|
||||||
|
|
||||||
|
def test_semi_monthly_window(self):
|
||||||
|
s1, e1 = compute_pay_period('semi_monthly', '', date(2026, 3, 10))
|
||||||
|
self.assertEqual((s1, e1), (date(2026, 3, 1), date(2026, 3, 15)))
|
||||||
|
s2, e2 = compute_pay_period('semi_monthly', '', date(2026, 3, 20))
|
||||||
|
self.assertEqual((s2, e2), (date(2026, 3, 16), date(2026, 3, 31)))
|
||||||
|
|
||||||
|
def test_period_length_days(self):
|
||||||
|
self.assertEqual(period_length_days('weekly'), 7)
|
||||||
|
self.assertEqual(period_length_days('biweekly'), 14)
|
||||||
|
self.assertIsNone(period_length_days('monthly'))
|
||||||
|
|
||||||
|
def test_current_prev_next_are_contiguous(self):
|
||||||
|
w = current_prev_next('biweekly', '2026-05-04', date(2026, 5, 20))
|
||||||
|
self.assertEqual(w['current'], (date(2026, 5, 18), date(2026, 5, 31)))
|
||||||
|
self.assertEqual(w['previous'][1], w['current'][0] - timedelta(days=1))
|
||||||
|
self.assertEqual(w['next'][0], w['current'][1] + timedelta(days=1))
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestPayPeriodFilters(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
self.today = get_local_today(self.env)
|
||||||
|
# Make TODAY the first day of the current bi-weekly window.
|
||||||
|
self.ICP.set_param('fusion_clock.pay_period_type', 'biweekly')
|
||||||
|
self.ICP.set_param('fusion_clock.pay_period_start', str(self.today))
|
||||||
|
self.emp = self.env['hr.employee'].create({'name': 'Filter Fred'})
|
||||||
|
Att = self.env['hr.attendance']
|
||||||
|
# current period: today .. today+13 -> attendance "now"
|
||||||
|
self.att_current = Att.create({
|
||||||
|
'employee_id': self.emp.id,
|
||||||
|
'check_in': fields.Datetime.now(),
|
||||||
|
'check_out': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
# previous period: today-14 .. today-1 -> attendance 8 days ago
|
||||||
|
eight_ago = fields.Datetime.now() - timedelta(days=8)
|
||||||
|
self.att_prev = Att.create({
|
||||||
|
'employee_id': self.emp.id,
|
||||||
|
'check_in': eight_ago,
|
||||||
|
'check_out': eight_ago,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_current_filter_returns_only_current(self):
|
||||||
|
res = self.env['hr.attendance'].search([
|
||||||
|
('employee_id', '=', self.emp.id),
|
||||||
|
('x_fclk_in_current_period', '=', True),
|
||||||
|
])
|
||||||
|
self.assertIn(self.att_current, res)
|
||||||
|
self.assertNotIn(self.att_prev, res)
|
||||||
|
|
||||||
|
def test_previous_filter_returns_only_previous(self):
|
||||||
|
res = self.env['hr.attendance'].search([
|
||||||
|
('employee_id', '=', self.emp.id),
|
||||||
|
('x_fclk_in_previous_period', '=', True),
|
||||||
|
])
|
||||||
|
self.assertIn(self.att_prev, res)
|
||||||
|
self.assertNotIn(self.att_current, res)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestPeriodPickerWizard(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
self.ICP.set_param('fusion_clock.pay_period_type', 'biweekly')
|
||||||
|
self.ICP.set_param('fusion_clock.pay_period_start', '2026-05-04')
|
||||||
|
self.today = get_local_today(self.env)
|
||||||
|
|
||||||
|
def test_default_start_is_current_period_start(self):
|
||||||
|
wiz = self.env['fusion.clock.period.picker'].create({})
|
||||||
|
expected_start = current_prev_next('biweekly', '2026-05-04', self.today)['current'][0]
|
||||||
|
self.assertEqual(wiz.date_start, expected_start)
|
||||||
|
|
||||||
|
def test_onchange_autofills_two_weeks(self):
|
||||||
|
wiz = self.env['fusion.clock.period.picker'].new({'date_start': date(2026, 6, 1)})
|
||||||
|
wiz._onchange_date_start()
|
||||||
|
self.assertEqual(wiz.date_end, date(2026, 6, 1) + timedelta(days=13))
|
||||||
|
|
||||||
|
def test_action_apply_returns_attendance_domain(self):
|
||||||
|
wiz = self.env['fusion.clock.period.picker'].create({
|
||||||
|
'date_start': date(2026, 6, 1), 'date_end': date(2026, 6, 14),
|
||||||
|
})
|
||||||
|
act = wiz.action_apply()
|
||||||
|
self.assertEqual(act['res_model'], 'hr.attendance')
|
||||||
|
self.assertEqual(act['view_mode'], 'list,form')
|
||||||
|
leaves = [leaf for leaf in act['domain'] if isinstance(leaf, tuple)]
|
||||||
|
self.assertTrue(any(leaf[0] == 'check_in' and leaf[1] == '>=' for leaf in leaves))
|
||||||
|
self.assertTrue(any(leaf[0] == 'check_in' and leaf[1] == '<' for leaf in leaves))
|
||||||
@@ -49,6 +49,13 @@
|
|||||||
sequence="10"
|
sequence="10"
|
||||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_clock_biweekly_period"
|
||||||
|
name="Bi-Weekly Period"
|
||||||
|
parent="menu_fusion_clock_attendance"
|
||||||
|
action="action_fusion_clock_period_picker"
|
||||||
|
sequence="15"
|
||||||
|
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||||
|
|
||||||
<menuitem id="menu_fusion_clock_leaves"
|
<menuitem id="menu_fusion_clock_leaves"
|
||||||
name="Leave Requests"
|
name="Leave Requests"
|
||||||
parent="menu_fusion_clock_attendance"
|
parent="menu_fusion_clock_attendance"
|
||||||
|
|||||||
@@ -88,6 +88,10 @@
|
|||||||
<filter name="fclk_has_penalty" string="Has Penalty" domain="[('x_fclk_penalty_ids', '!=', False)]"/>
|
<filter name="fclk_has_penalty" string="Has Penalty" domain="[('x_fclk_penalty_ids', '!=', False)]"/>
|
||||||
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
|
<filter name="fclk_period_current" string="Current Pay Period" domain="[('x_fclk_in_current_period', '=', True)]"/>
|
||||||
|
<filter name="fclk_period_previous" string="Previous Pay Period" domain="[('x_fclk_in_previous_period', '=', True)]"/>
|
||||||
|
<filter name="fclk_period_next" string="Next Pay Period" domain="[('x_fclk_in_next_period', '=', True)]"/>
|
||||||
|
<separator/>
|
||||||
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
||||||
<filter name="group_source" string="Source" context="{'group_by': 'x_fclk_clock_source'}"/>
|
<filter name="group_source" string="Source" context="{'group_by': 'x_fclk_clock_source'}"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<block title="Pay Period & Reports" name="fclk_pay_period_reports">
|
<block title="Pay Period & Reports" name="fclk_pay_period_reports">
|
||||||
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
||||||
help="Defines how often attendance reports are generated and the start/end dates of each reporting period.">
|
help="Defines how often attendance reports are generated and the start/end dates of each period. The Anchor Date is the pay-period start used by both the reports AND the Bi-Weekly Period filter/picker on the Attendances list.">
|
||||||
<div class="content-group">
|
<div class="content-group">
|
||||||
<div class="row mt16">
|
<div class="row mt16">
|
||||||
<label for="fclk_pay_period_type" string="Frequency" class="col-lg-5 o_light_label"/>
|
<label for="fclk_pay_period_type" string="Frequency" class="col-lg-5 o_light_label"/>
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
from . import clock_nfc_enrollment_wizard
|
from . import clock_nfc_enrollment_wizard
|
||||||
|
from . import clock_period_picker_wizard
|
||||||
|
|||||||
36
fusion_clock/wizard/clock_period_picker_views.xml
Normal file
36
fusion_clock/wizard/clock_period_picker_views.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_period_picker_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.period.picker.form</field>
|
||||||
|
<field name="model">fusion.clock.period.picker</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Bi-Weekly Period">
|
||||||
|
<sheet>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
Pick the period start — the end auto-fills to one pay period later
|
||||||
|
(two weeks by default). Adjust either date, then click
|
||||||
|
<b>View Attendances</b>.
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="date_start"/>
|
||||||
|
<field name="date_end"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_apply" string="View Attendances" type="object" class="btn-primary"/>
|
||||||
|
<button special="cancel" string="Cancel" class="btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_period_picker" model="ir.actions.act_window">
|
||||||
|
<field name="name">Bi-Weekly Period</field>
|
||||||
|
<field name="res_model">fusion.clock.period.picker</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="view_id" ref="view_fusion_clock_period_picker_form"/>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
66
fusion_clock/wizard/clock_period_picker_wizard.py
Normal file
66
fusion_clock/wizard/clock_period_picker_wizard.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from ..models.pay_period import compute_pay_period, period_length_days, current_prev_next
|
||||||
|
from ..models.tz_utils import get_local_today, get_local_day_boundaries
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockPeriodPicker(models.TransientModel):
|
||||||
|
"""Pick a pay-period window and open the attendance list filtered to it.
|
||||||
|
|
||||||
|
Defaults to the current pay period. Changing the start auto-fills the end
|
||||||
|
to one pay period later (two weeks by default); the end stays editable so a
|
||||||
|
fully custom range can be entered too.
|
||||||
|
"""
|
||||||
|
_name = 'fusion.clock.period.picker'
|
||||||
|
_description = 'Bi-Weekly Period Picker'
|
||||||
|
|
||||||
|
date_start = fields.Date(string='Period Start', required=True,
|
||||||
|
default=lambda self: self._fclk_default_window()[0])
|
||||||
|
date_end = fields.Date(string='Period End', required=True,
|
||||||
|
default=lambda self: self._fclk_default_window()[1])
|
||||||
|
|
||||||
|
def _fclk_config(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
return (ICP.get_param('fusion_clock.pay_period_type', 'biweekly'),
|
||||||
|
ICP.get_param('fusion_clock.pay_period_start', ''))
|
||||||
|
|
||||||
|
def _fclk_default_window(self):
|
||||||
|
frequency, anchor = self._fclk_config()
|
||||||
|
return current_prev_next(frequency, anchor, get_local_today(self.env))['current']
|
||||||
|
|
||||||
|
@api.onchange('date_start')
|
||||||
|
def _onchange_date_start(self):
|
||||||
|
if not self.date_start:
|
||||||
|
return
|
||||||
|
frequency, anchor = self._fclk_config()
|
||||||
|
length = period_length_days(frequency)
|
||||||
|
if length:
|
||||||
|
self.date_end = self.date_start + timedelta(days=length - 1)
|
||||||
|
else:
|
||||||
|
self.date_end = compute_pay_period(frequency, anchor, self.date_start)[1]
|
||||||
|
|
||||||
|
@api.constrains('date_start', 'date_end')
|
||||||
|
def _check_dates(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.date_start and rec.date_end and rec.date_end < rec.date_start:
|
||||||
|
raise ValidationError(_("Period end cannot be before period start."))
|
||||||
|
|
||||||
|
def action_apply(self):
|
||||||
|
self.ensure_one()
|
||||||
|
start_utc, _dummy = get_local_day_boundaries(self.env, self.date_start)
|
||||||
|
_dummy2, end_excl_utc = get_local_day_boundaries(self.env, self.date_end)
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Attendances · %s – %s") % (self.date_start, self.date_end),
|
||||||
|
'res_model': 'hr.attendance',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': ['&',
|
||||||
|
('check_in', '>=', fields.Datetime.to_string(start_utc)),
|
||||||
|
('check_in', '<', fields.Datetime.to_string(end_excl_utc))],
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user