diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 9b4b4517..a0a816e3 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.14.4', + 'version': '19.0.3.15.0', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ @@ -73,6 +73,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'views/clock_schedule_views.xml', # Wizards (must load before clock_menus.xml since menu references wizard action) 'wizard/clock_nfc_enrollment_views.xml', + 'wizard/clock_period_picker_views.xml', 'views/clock_menus.xml', # Views - Portal 'views/portal_clock_templates.xml', diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index 46f8ba48..fa59a31a 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from . import pay_period from . import clock_location from . import hr_attendance from . import hr_employee diff --git a/fusion_clock/models/clock_report.py b/fusion_clock/models/clock_report.py index 3dc51995..ea435aca 100644 --- a/fusion_clock/models/clock_report.py +++ b/fusion_clock/models/clock_report.py @@ -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): diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index c72ffebf..95a8c3fa 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -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: diff --git a/fusion_clock/models/pay_period.py b/fusion_clock/models/pay_period.py new file mode 100644 index 00000000..40f0f240 --- /dev/null +++ b/fusion_clock/models/pay_period.py @@ -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} diff --git a/fusion_clock/static/src/js/fusion_clock_dashboard.js b/fusion_clock/static/src/js/fusion_clock_dashboard.js index a0ab3615..f86a05a0 100644 --- a/fusion_clock/static/src/js/fusion_clock_dashboard.js +++ b/fusion_clock/static/src/js/fusion_clock_dashboard.js @@ -87,6 +87,7 @@ export class FusionClockDashboard extends Component { onViewActivityLogs() { this.action.doAction("fusion_clock.action_fusion_clock_activity_log"); } onViewPenalties() { this.action.doAction("fusion_clock.action_fusion_clock_penalty"); } 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"); } } diff --git a/fusion_clock/static/src/xml/fusion_clock_dashboard.xml b/fusion_clock/static/src/xml/fusion_clock_dashboard.xml index d565340c..4a243a4b 100644 --- a/fusion_clock/static/src/xml/fusion_clock_dashboard.xml +++ b/fusion_clock/static/src/xml/fusion_clock_dashboard.xml @@ -125,6 +125,7 @@ ๐Ÿ“จ Approvals โš  Penalties ๐Ÿ—’ Activity Logs + ๐Ÿ—“ Bi-Weekly Period ๐Ÿ“… Shift Planner diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index ec47f256..2a10a4dd 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_shift_planner from . import test_photo_retention from . import test_schedule_driven from . import test_dashboard +from . import test_pay_period diff --git a/fusion_clock/tests/test_pay_period.py b/fusion_clock/tests/test_pay_period.py new file mode 100644 index 00000000..d3bf14bc --- /dev/null +++ b/fusion_clock/tests/test_pay_period.py @@ -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)) diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index 8f7daddd..638a0218 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -49,6 +49,13 @@ sequence="10" groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/> + + + + + + diff --git a/fusion_clock/views/res_config_settings_views.xml b/fusion_clock/views/res_config_settings_views.xml index f0bafb35..3cb52fc5 100644 --- a/fusion_clock/views/res_config_settings_views.xml +++ b/fusion_clock/views/res_config_settings_views.xml @@ -193,7 +193,7 @@ + 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.">