fix(fusion_clock): stop stale missed-clock-in nag; add Owner role + attendance exemption

The "explain your missed clock-out" dialog (driven by hr.employee.
x_fclk_pending_reason) was set by the absence + auto-clock-out crons but only
cleared by the systray reason dialog -- never by the kiosk/NFC clock paths that
staff actually use. During the kiosk rollout the absence cron flagged the whole
company (hundreds of "absent" logs); those stale flags then nagged everyone
forever, even while currently clocked in.

Fixes:
- Clear x_fclk_pending_reason on every successful clock-in (portal, systray,
  PIN kiosk, NFC kiosk). Back on the clock => no nag.
- get_status / dashboard never report pending while checked-in or exempt; the
  systray also guards the dialog client-side.
- Absence detection no longer sets x_fclk_pending_reason (an absence has no
  "departure time" to explain). It still logs 'absent' + notifies the office.
- One-time migration (19.0.4.2.0) clears existing stale flags.

Owner / attendance exemption:
- New "Owner" role (top of the Fusion Clock access dropdown, implies Manager)
  plus a per-employee "Exempt from Attendance" checkbox.
- hr.employee._fclk_is_attendance_exempt(); the absence, auto-clock-out,
  reminder and weekly-summary crons all skip exempt employees, and the dialog
  is suppressed for them.

Tests: tests/test_pending_reason_exempt.py (13 cases). Full fusion_clock suite
green except pre-existing env-sensitive failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-02 17:54:00 -04:00
parent 71f4c41d5c
commit 78fa8f07ee
12 changed files with 344 additions and 5 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.4.1.0',
'version': '19.0.4.2.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """

View File

@@ -287,6 +287,11 @@ class FusionClockAPI(http.Controller):
attendance.sudo().write(write_vals)
# A successful clock-in resolves any pending missed-clock-out flag,
# so the employee is never nagged once they are back on the clock.
if employee.x_fclk_pending_reason:
employee.sudo().write({'x_fclk_pending_reason': False})
# Log clock-in
self._log_activity(
employee, 'clock_in',
@@ -542,7 +547,10 @@ class FusionClockAPI(http.Controller):
'is_checked_in': is_checked_in,
'employee_name': employee.name,
'enable_clock': employee.x_fclk_enable_clock,
'pending_reason': employee.x_fclk_pending_reason,
# Only nag when there is genuinely something to explain: a flag set,
# the employee NOT currently on the clock, and not attendance-exempt.
'pending_reason': (employee.x_fclk_pending_reason and not is_checked_in
and not employee._fclk_is_attendance_exempt()),
'ontime_streak': employee.x_fclk_ontime_streak,
}
local_today = get_local_today(request.env, employee)
@@ -728,7 +736,8 @@ class FusionClockAPI(http.Controller):
'is_checked_in': is_checked_in,
'check_in': check_in,
'location_name': location_name,
'pending_reason': employee.x_fclk_pending_reason,
'pending_reason': (employee.x_fclk_pending_reason and not is_checked_in
and not employee._fclk_is_attendance_exempt()),
'today_hours': today_hours,
'week_hours': week_hours,
'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2),

View File

@@ -137,6 +137,9 @@ class FusionClockKiosk(http.Controller):
'x_fclk_clock_source': 'kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
# Back on the clock -> clear any stale missed-clock-out flag.
if employee.x_fclk_pending_reason:
employee.sudo().write({'x_fclk_pending_reason': False})
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0, source='kiosk')

View File

@@ -345,6 +345,9 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_clock_source': 'nfc_kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
# Back on the clock -> clear any stale missed-clock-out flag.
if employee.x_fclk_pending_reason:
employee.sudo().write({'x_fclk_pending_reason': False})
api._log_activity(
employee, 'clock_in',
f"NFC kiosk clock-in at {location.name}",

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""One-time reset of stale missed-clock-out flags on upgrade to 19.0.4.1.0.
Background: x_fclk_pending_reason was set by the absence + auto-clock-out crons
but only cleared by the systray reason dialog -- never by the kiosk / NFC clock
paths that staff actually use. During the kiosk rollout the absence cron flagged
essentially the whole company (hundreds of "absent" logs), and those flags then
nagged everyone forever, even while currently clocked in.
This release clears the flag on every clock-in (all paths), stops absences from
setting it at all, and exempts owners. The flags already on record are stale
artifacts of the rollout, so wipe them once here; correct ones re-appear only
for a genuine forgotten clock-out from now on.
"""
def migrate(cr, version):
if not version:
return
cr.execute(
"UPDATE hr_employee SET x_fclk_pending_reason = false "
"WHERE x_fclk_pending_reason = true"
)

View File

@@ -345,6 +345,9 @@ class HrAttendance(models.Model):
continue
employee = att.employee_id
# Owners / attendance-exempt employees are never auto-clocked-out or nagged.
if employee._fclk_is_attendance_exempt():
continue
clock_out_time = effective_deadline
try:
with self.env.cr.savepoint():
@@ -456,6 +459,9 @@ class HrAttendance(models.Model):
for emp in employees:
try:
with self.env.cr.savepoint():
# Owners / attendance-exempt employees are never flagged absent.
if emp._fclk_is_attendance_exempt():
continue
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
# Only days the employee was actually scheduled to work
@@ -498,7 +504,11 @@ class HrAttendance(models.Model):
'source': 'system',
})
emp.sudo().write({'x_fclk_pending_reason': True})
# NOTE: an absence does NOT set x_fclk_pending_reason. That flag
# drives the "explain your missed clock-OUT (departure time)"
# dialog, which is meaningless for a day with no attendance and
# caused a persistent false nag. The absence is logged + the
# office is notified on excess; that is the absence remedy.
month_start = yesterday.replace(day=1)
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
@@ -546,6 +556,9 @@ class HrAttendance(models.Model):
for emp in employees:
try:
with self.env.cr.savepoint():
# Owners / attendance-exempt employees are never reminded.
if emp._fclk_is_attendance_exempt():
continue
today = get_local_today(self.env, emp)
if not emp._get_fclk_day_plan(today).get('scheduled'):
continue
@@ -610,6 +623,9 @@ class HrAttendance(models.Model):
company_name = company.name or ''
for emp in employees:
# Owners / attendance-exempt employees get no weekly summary.
if emp._fclk_is_attendance_exempt():
continue
if not emp.work_email:
continue

View File

@@ -40,6 +40,18 @@ class HrEmployee(models.Model):
help="If set, employee must explain a missed clock-out before clocking in again.",
)
# Attendance exemption (owners / anyone who works but is not "on the clock").
# Exempt employees are skipped by absence detection, auto-clock-out and
# reminders, and never see the missed-clock-out reason dialog.
x_fclk_exempt_from_attendance = fields.Boolean(
string='Exempt from Attendance Tracking',
default=False,
help="If set, this employee is never flagged absent, auto-clocked-out, "
"reminded, or asked to explain a missed clock-out. Use for owners "
"and others who work but are not on the clock. The Fusion Clock "
"'Owner' role grants this automatically.",
)
# Kiosk PIN
x_fclk_kiosk_pin = fields.Char(
string='Kiosk PIN',
@@ -122,6 +134,19 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _fclk_is_attendance_exempt(self):
"""True when this employee is exempt from attendance automation.
Exempt = the per-employee checkbox is set, OR the linked user holds the
Fusion Clock 'Owner' role. Exempt employees are never flagged absent,
auto-clocked-out, reminded, or shown the missed-clock-out reason dialog.
"""
self.ensure_one()
if self.x_fclk_exempt_from_attendance:
return True
user = self.user_id
return bool(user) and user.has_group('fusion_clock.group_fusion_clock_owner')
def _get_fclk_schedule_for_date(self, date):
"""Return this employee's dated Fusion Clock schedule for a local date."""
self.ensure_one()

View File

@@ -49,6 +49,18 @@
<field name="comment">Can manage locations, view all attendance, generate reports</field>
</record>
<!-- Owner: top of the role ladder. Carries ALL Manager permissions but is
exempt from attendance automation (no absence flags, no auto-clock-out
nag, no reminders, no missed-clock-out dialog). For owners/principals
who work but are not "on the clock". Implies Manager, so it renders as
the highest role in the single Fusion Clock access dropdown. -->
<record id="group_fusion_clock_owner" model="res.groups">
<field name="name">Owner</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_manager'))]"/>
<field name="comment">Full Clock management; exempt from attendance tracking, reminders and missed-clock alerts.</field>
</record>
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.

View File

@@ -71,7 +71,10 @@ export class FusionClockFAB extends Component {
this.state.todayHours = (result.today_hours || 0).toFixed(1);
this.state.weekHours = (result.week_hours || 0).toFixed(1);
if (result.pending_reason) {
// Never raise the missed-clock-out dialog while the employee is
// currently on the clock (the server already guards this, but keep
// the UI honest too).
if (result.pending_reason && !result.is_checked_in) {
this.state.showReasonDialog = true;
}

View File

@@ -10,3 +10,4 @@ from . import test_pay_period
from . import test_settings
from . import test_clock_kiosk
from . import test_break_rules
from . import test_pending_reason_exempt

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Regression tests for the missed-clock-out ("pending reason") nag and the
new owner/attendance-exemption.
Root cause these tests pin down:
* The `x_fclk_pending_reason` flag was set by the absence + auto-clock-out
crons but ONLY cleared by the systray reason dialog. The kiosk / NFC clock
paths (how Entech actually clocks in) never cleared it, so a stale flag
nagged employees forever -- even while currently clocked in.
* Owners work but are not "on the clock"; they must be exempt from absence
flagging, auto-clock-out nags and the reason dialog.
"""
import json
from datetime import date, timedelta
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import HttpCase, TransactionCase
try:
from freezegun import freeze_time
except ImportError: # freezegun may be absent on the runtime image
freeze_time = None
MON = date(2026, 6, 1) # Monday
TUE = date(2026, 6, 2) # Tuesday
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestAttendanceExemptHelper(TransactionCase):
"""`hr.employee._fclk_is_attendance_exempt()` truth table."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.owner_group = cls.env.ref('fusion_clock.group_fusion_clock_owner')
def test_plain_employee_not_exempt(self):
emp = self.Employee.create({'name': 'Plain', 'x_fclk_enable_clock': True})
self.assertFalse(emp._fclk_is_attendance_exempt())
def test_checkbox_makes_exempt(self):
emp = self.Employee.create({
'name': 'Flagged', 'x_fclk_enable_clock': True,
'x_fclk_exempt_from_attendance': True,
})
self.assertTrue(emp._fclk_is_attendance_exempt())
def test_owner_group_makes_exempt(self):
user = self.env['res.users'].create({
'name': 'Olivia Owner', 'login': 'olivia-owner-test',
'group_ids': [(4, self.owner_group.id)],
})
emp = self.Employee.create({
'name': 'Olivia Owner', 'x_fclk_enable_clock': True, 'user_id': user.id,
})
self.assertTrue(emp._fclk_is_attendance_exempt())
def test_owner_group_implies_manager(self):
"""The Owner role must carry full Manager permissions."""
user = self.env['res.users'].create({
'name': 'Manager-by-owner', 'login': 'owner-implies-mgr',
'group_ids': [(4, self.owner_group.id)],
})
self.assertTrue(user.has_group('fusion_clock.group_fusion_clock_manager'))
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestCronsRespectExemptAndPending(TransactionCase):
"""Absence + auto-clock-out crons: no more pending nag, owners skipped."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.Schedule = cls.env['fusion.clock.schedule']
cls.Attendance = cls.env['hr.attendance']
cls.Log = cls.env['fusion.clock.activity.log']
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_auto_clockout', 'True')
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
def _post(self, emp, day):
return self.Schedule.create({
'employee_id': emp.id, 'schedule_date': day, 'state': 'posted',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
})
def test_absence_does_not_set_pending_reason(self):
if freeze_time is None:
self.skipTest("freezegun not available")
emp = self.Employee.create({'name': 'NoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
self._post(emp, MON)
with freeze_time("2026-06-02 09:00:00"): # yesterday = scheduled Monday
self.Attendance._cron_fusion_check_absences()
# Absence is still logged ...
self.assertEqual(self.Log.search_count([
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 1)
# ... but it must NOT raise the missed-clock-out reason nag.
self.assertFalse(emp.x_fclk_pending_reason)
def test_absence_skips_exempt_employee(self):
if freeze_time is None:
self.skipTest("freezegun not available")
emp = self.Employee.create({
'name': 'OwnerNoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'x_fclk_exempt_from_attendance': True,
})
self._post(emp, MON)
with freeze_time("2026-06-02 09:00:00"):
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 0)
self.assertFalse(emp.x_fclk_pending_reason)
def test_auto_clockout_skips_exempt_employee(self):
emp = self.Employee.create({
'name': 'OwnerStale', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'x_fclk_exempt_from_attendance': True,
})
now = fields.Datetime.now()
stale = self.Attendance.create({
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertFalse(stale.check_out, "Exempt employee must not be auto-clocked-out.")
self.assertFalse(emp.x_fclk_pending_reason)
def test_auto_clockout_still_flags_normal_employee(self):
emp = self.Employee.create({'name': 'Forgetful', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
now = fields.Datetime.now()
stale = self.Attendance.create({
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")
self.assertTrue(emp.x_fclk_pending_reason, "Forgotten clock-out still asks for a reason.")
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestKioskClearsPendingReason(HttpCase):
"""Clocking in via either kiosk clears a stale pending-reason flag."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Clear Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'Clear Op', 'login': 'clear-op', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.pin_emp = cls.env['hr.employee'].create({
'name': 'Pat Pending', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
'x_fclk_pending_reason': True,
})
cls.nfc_emp = cls.env['hr.employee'].create({
'name': 'Nina Pending', 'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:CC:01', 'x_fclk_pending_reason': True,
})
def setUp(self):
super().setUp()
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_mod
nfc_mod._recent_taps.clear()
def _post(self, route, params):
self.authenticate('clear-op', 'kioskpass123')
resp = self.url_open(route, data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': params,
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_pin_kiosk_clock_in_clears_pending(self):
res = self._post('/fusion_clock/kiosk/clock', {'employee_id': self.pin_emp.id})
self.assertEqual(res.get('action'), 'clock_in')
self.pin_emp.invalidate_recordset()
self.assertFalse(self.pin_emp.x_fclk_pending_reason)
def test_nfc_tap_clock_in_clears_pending(self):
res = self._post('/fusion_clock/kiosk/nfc/tap', {'card_uid': '04:A2:B5:62:CC:01'})
self.assertEqual(res.get('action'), 'clock_in')
self.nfc_emp.invalidate_recordset()
self.assertFalse(self.nfc_emp.x_fclk_pending_reason)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestGetStatusPendingReason(HttpCase):
"""get_status must never raise the dialog for a clocked-in or exempt user."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = cls.env['res.users'].create({
'name': 'Status User', 'login': 'status-user', 'password': 'statuspass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_user').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Status User', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'user_id': cls.user.id, 'x_fclk_pending_reason': True,
})
def _status(self):
self.authenticate('status-user', 'statuspass123')
resp = self.url_open('/fusion_clock/get_status', data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': {},
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_pending_hidden_while_checked_in(self):
self.env['hr.attendance'].create({
'employee_id': self.emp.id, 'check_in': fields.Datetime.now() - timedelta(hours=1),
})
self.emp.invalidate_recordset()
res = self._status()
self.assertTrue(res.get('is_checked_in'))
self.assertFalse(res.get('pending_reason'),
"A currently clocked-in employee must never be nagged.")
def test_pending_hidden_for_exempt(self):
self.emp.write({'x_fclk_exempt_from_attendance': True})
res = self._status()
self.assertFalse(res.get('is_checked_in'))
self.assertFalse(res.get('pending_reason'),
"An exempt (owner) employee must never be nagged.")
def test_pending_shown_for_normal_not_checked_in(self):
"""Sanity: the dialog still works for a genuine forgotten clock-out."""
res = self._status()
self.assertFalse(res.get('is_checked_in'))
self.assertTrue(res.get('pending_reason'))

View File

@@ -15,6 +15,7 @@
<group>
<group string="Configuration">
<field name="x_fclk_enable_clock"/>
<field name="x_fclk_exempt_from_attendance"/>
<field name="x_fclk_shift_id"/>
<field name="x_fclk_default_location_id"/>
<field name="x_fclk_break_minutes"/>