feat(fusion_clock): province-aware automatic unpaid break (2-tier)

Statutory unpaid break now deducts automatically from worked hours on every path - portal, kiosk, NFC, auto-clock-out cron, AND manual backend entry.

- new fusion.clock.break.rule per-province table (seed Ontario 5h->30, 10h->+30), resolved from the employee's company province with a global default fallback
- x_fclk_break_minutes is now a single idempotent stored compute (statutory(worked_hours) + penalties), replacing the 4 duplicated write sites (_apply_break_deduction x3 callsites + auto-clock-out cron + penalty write)
- retire break_threshold_hours (superseded by per-rule break1_after_hours); post-migrate drops the param and recomputes historical breaks
- 11 tests all green; module install + 19.0.4.1.0 migration verified on modsdev

Bump 19.0.4.0.3 -> 19.0.4.1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-01 00:15:42 -04:00
parent 96b3f124f8
commit f7ec1e28f9
20 changed files with 383 additions and 68 deletions

View File

@@ -5,7 +5,7 @@
## 1. What This Module Is ## 1. What This Module Is
- **Name**: Fusion Clock. - **Name**: Fusion Clock.
- **Version**: `19.0.3.3.0`. - **Version**: `19.0.4.1.0`.
- **Category**: Human Resources/Attendances. - **Category**: Human Resources/Attendances.
- **License**: OPL-1, Nexa Systems Inc. - **License**: OPL-1, Nexa Systems Inc.
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`. - **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
@@ -68,6 +68,7 @@ Custom models:
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. | | `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. | | `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. | | `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
| `fusion.clock.break.rule` | `models/clock_break_rule.py` | Per-province statutory unpaid-break thresholds (2-tier: first break after N1 h, second after N2 h). |
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. | | `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
Inherited models: Inherited models:
@@ -101,7 +102,7 @@ Clock-out flow:
1. Verify location again. 1. Verify location again.
2. Call `_attendance_action_change()`. 2. Call `_attendance_action_change()`.
3. Write out-distance. 3. Write out-distance.
4. Apply break deduction when configured. 4. Break is deducted automatically — `x_fclk_break_minutes` is a stored compute (see §13), not an explicit controller step.
5. Create `early_out` penalty when outside grace. 5. Create `early_out` penalty when outside grace.
6. Log `clock_out`. 6. Log `clock_out`.
7. Log overtime if computed overtime is positive. 7. Log overtime if computed overtime is positive.
@@ -252,7 +253,6 @@ fusion_clock.default_clock_in_time
fusion_clock.default_clock_out_time fusion_clock.default_clock_out_time
fusion_clock.default_break_minutes fusion_clock.default_break_minutes
fusion_clock.auto_deduct_break fusion_clock.auto_deduct_break
fusion_clock.break_threshold_hours
fusion_clock.enable_auto_clockout fusion_clock.enable_auto_clockout
fusion_clock.max_shift_hours fusion_clock.max_shift_hours
fusion_clock.enable_penalties fusion_clock.enable_penalties
@@ -327,8 +327,8 @@ All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets. - Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons. - `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field. - **`hr.attendance.x_fclk_break_minutes` is a stored COMPUTE, not a writable field** (`_compute_fclk_break_minutes`): statutory break (per the employee's province `fusion.clock.break.rule`, from actual `worked_hours`, 2-tier — first break after N1 h, second after N2 h, inclusive `>=`) **plus** Σ penalty minutes. It recomputes on every path incl. manual backend create/edit, which is what makes the break auto-apply on manually-entered hours. NEVER `write()` it — change the province rule or toggle `fusion_clock.auto_deduct_break` instead. Penalty minutes are now strictly additive (the old controller `max()` that could swallow a late clock-in penalty is gone). Rule resolved via `hr.employee._get_fclk_break_rule()` (company `state_id` → matching rule → global `is_default` rule). The retired `break_threshold_hours` setting is superseded by per-rule `break1_after_hours`.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. - `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. **Gotcha: `worked_hours` itself subtracts the resource-calendar lunch interval for NON-flexible employees** (Odoo core `hr.attendance._get_worked_hours_in_range`), so the statutory tiers run on lunch-excluded hours; flexible / no-calendar employees get the raw check_in→check_out span. Tests that need a deterministic span give the employee a `flexible_hours` calendar.
- Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.) - Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.)
- `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on). - `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on).
- **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`. - **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`.

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Clock', 'name': 'Fusion Clock',
'version': '19.0.4.0.3', 'version': '19.0.4.1.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': """
@@ -52,6 +52,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'security/ir.model.access.csv', 'security/ir.model.access.csv',
# Data # Data
'data/ir_config_parameter_data.xml', 'data/ir_config_parameter_data.xml',
'data/clock_break_rule_data.xml',
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
# Reports (must load before mail templates that reference them) # Reports (must load before mail templates that reference them)
'report/clock_report_template.xml', 'report/clock_report_template.xml',
@@ -71,6 +72,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_dashboard_views.xml', 'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml', 'views/hr_employee_views.xml',
'views/clock_schedule_views.xml', 'views/clock_schedule_views.xml',
'views/clock_break_rule_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', 'wizard/clock_period_picker_views.xml',

View File

@@ -5,7 +5,6 @@
import base64 import base64
import math import math
import logging import logging
import pytz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
@@ -137,12 +136,6 @@ class FusionClockAPI(http.Controller):
'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee), 'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee),
}) })
# Deduct penalty minutes from attendance (adds to break deduction)
current_break = attendance.x_fclk_break_minutes or 0.0
attendance.sudo().write({
'x_fclk_break_minutes': current_break + deduction,
})
# Log penalty # Log penalty
log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out' log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
request.env['fusion.clock.activity.log'].sudo().create({ request.env['fusion.clock.activity.log'].sudo().create({
@@ -158,32 +151,6 @@ class FusionClockAPI(http.Controller):
if penalty_type == 'late_in': if penalty_type == 'late_in':
employee.sudo().write({'x_fclk_ontime_streak': 0}) employee.sudo().write({'x_fclk_ontime_streak': 0})
def _apply_break_deduction(self, attendance, employee):
"""Apply automatic break deduction if configured."""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
return
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
worked = attendance.worked_hours or 0.0
if worked >= threshold:
local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
if new_val != current:
attendance.sudo().write({'x_fclk_break_minutes': new_val})
def _log_activity(self, employee, log_type, description, attendance=None, def _log_activity(self, employee, log_type, description, attendance=None,
location=None, latitude=0, longitude=0, distance=0, source='portal'): location=None, latitude=0, longitude=0, distance=0, source='portal'):
"""Create an activity log entry.""" """Create an activity log entry."""
@@ -405,9 +372,6 @@ class FusionClockAPI(http.Controller):
'x_fclk_out_distance': round(distance, 1), 'x_fclk_out_distance': round(distance, 1),
}) })
# Apply break deduction
self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty # Check for early clock-out penalty
if not is_scheduled_off: if not is_scheduled_off:
_, scheduled_out = self._get_scheduled_times(employee, today) _, scheduled_out = self._get_scheduled_times(employee, today)

View File

@@ -155,7 +155,6 @@ class FusionClockKiosk(http.Controller):
'x_fclk_out_distance': 0.0, 'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
}) })
api._apply_break_deduction(attendance, employee)
if not is_scheduled_off: if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today) _, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)

View File

@@ -378,7 +378,6 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_out_distance': 0.0, 'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
}) })
api._apply_break_deduction(attendance, employee)
if not is_scheduled_off: if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today) _, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="break_rule_ontario" model="fusion.clock.break.rule">
<field name="name">Ontario</field>
<field name="country_id" ref="base.ca"/>
<field name="state_id" ref="base.state_ca_on"/>
<field name="is_default" eval="True"/>
<field name="break1_after_hours">5.0</field>
<field name="break1_minutes">30.0</field>
<field name="break2_after_hours">10.0</field>
<field name="break2_minutes">30.0</field>
</record>
</odoo>

View File

@@ -20,10 +20,6 @@
<field name="key">fusion_clock.auto_deduct_break</field> <field name="key">fusion_clock.auto_deduct_break</field>
<field name="value">True</field> <field name="value">True</field>
</record> </record>
<record id="config_break_threshold_hours" model="ir.config_parameter">
<field name="key">fusion_clock.break_threshold_hours</field>
<field name="value">4.0</field>
</record>
<!-- Auto Clock-Out --> <!-- Auto Clock-Out -->
<record id="config_enable_auto_clockout" model="ir.config_parameter"> <record id="config_enable_auto_clockout" model="ir.config_parameter">

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, SUPERUSER_ID
def migrate(cr, version):
"""Retire the single-threshold break param (superseded by per-rule
break1_after_hours), and force-recompute the now-computed break field so
existing closed attendances reflect the province rule + their penalties."""
cr.execute(
"DELETE FROM ir_config_parameter WHERE key = %s",
('fusion_clock.break_threshold_hours',),
)
env = api.Environment(cr, SUPERUSER_ID, {})
Attendance = env['hr.attendance']
field = Attendance._fields['x_fclk_break_minutes']
closed = Attendance.search([('check_out', '!=', False)])
if closed:
env.add_to_compute(field, closed)
closed.flush_recordset(['x_fclk_break_minutes'])

View File

@@ -5,6 +5,7 @@ from . import clock_location
from . import hr_attendance from . import hr_attendance
from . import hr_employee from . import hr_employee
from . import clock_penalty from . import clock_penalty
from . import clock_break_rule
from . import clock_report from . import clock_report
from . import res_config_settings from . import res_config_settings
from . import clock_activity_log from . import clock_activity_log

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class FusionClockBreakRule(models.Model):
_name = 'fusion.clock.break.rule'
_description = 'Statutory Break Rule'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True)
country_id = fields.Many2one('res.country', string='Country')
state_id = fields.Many2one(
'res.country.state',
string='Province / State',
help="Employees whose company is in this province use this rule.",
)
is_default = fields.Boolean(
string='Default Rule',
help="Used when an employee's company province matches no other rule. "
"Only one active rule may be the default.",
)
break1_after_hours = fields.Float(
string='First Break After (h)', default=5.0,
help="Worked hours at or above this trigger the first unpaid break.",
)
break1_minutes = fields.Float(
string='First Break (min)', default=30.0,
help="Length of the first unpaid break. 0 disables it.",
)
break2_after_hours = fields.Float(
string='Second Break After (h)', default=10.0,
help="Worked hours at or above this add the second unpaid break.",
)
break2_minutes = fields.Float(
string='Second Break (min)', default=30.0,
help="Length of the second unpaid break. 0 disables it.",
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
def break_minutes_for(self, worked_hours):
"""Total statutory unpaid break (minutes) for the given worked hours.
Tiers are inclusive (``>=``): a break applies when worked hours are
equal to or greater than the threshold. The second tier adds on top of
the first.
"""
self.ensure_one()
worked = worked_hours or 0.0
total = 0.0
if self.break1_minutes and worked >= self.break1_after_hours:
total += self.break1_minutes
if self.break2_minutes and worked >= self.break2_after_hours:
total += self.break2_minutes
return total
@api.constrains('break1_after_hours', 'break1_minutes',
'break2_after_hours', 'break2_minutes')
def _check_tiers(self):
for rule in self:
if min(rule.break1_after_hours, rule.break1_minutes,
rule.break2_after_hours, rule.break2_minutes) < 0:
raise ValidationError(_("Break hours and minutes cannot be negative."))
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
raise ValidationError(_(
"The second break threshold (%(n2)s h) must be greater than "
"the first (%(n1)s h).",
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
@api.constrains('is_default', 'active')
def _check_single_default(self):
for rule in self:
if rule.is_default and rule.active:
dupe = self.search([
('is_default', '=', True), ('active', '=', True),
('id', '!=', rule.id),
], limit=1)
if dupe:
raise ValidationError(_(
"Only one active break rule can be the default "
"(currently: %s).", dupe.name))

View File

@@ -161,9 +161,12 @@ class HrAttendance(models.Model):
) )
x_fclk_break_minutes = fields.Float( x_fclk_break_minutes = fields.Float(
string='Break (min)', string='Break (min)',
default=0.0, compute='_compute_fclk_break_minutes',
store=True,
tracking=True, tracking=True,
help="Break duration in minutes to deduct from worked hours.", help="Unpaid break deducted from worked hours: statutory break (per the "
"employee's province rule, from actual hours worked) plus any penalty "
"minutes. Computed automatically on every save.",
) )
x_fclk_net_hours = fields.Float( x_fclk_net_hours = fields.Float(
string='Net Hours', string='Net Hours',
@@ -258,6 +261,20 @@ class HrAttendance(models.Model):
def _search_fclk_in_next_period(self, operator, value): def _search_fclk_in_next_period(self, operator, value):
return self._fclk_period_search('next', operator, value) return self._fclk_period_search('next', operator, value)
@api.depends('worked_hours', 'check_out',
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
def _compute_fclk_break_minutes(self):
ICP = self.env['ir.config_parameter'].sudo()
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
for att in self:
statutory = 0.0
if auto and att.check_out and att.employee_id:
rule = att.employee_id._get_fclk_break_rule()
if rule:
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
att.x_fclk_break_minutes = statutory + penalties
@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:
@@ -314,7 +331,6 @@ class HrAttendance(models.Model):
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0')) max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
now = fields.Datetime.now() now = fields.Datetime.now()
open_attendances = self.sudo().search([('check_out', '=', False)]) open_attendances = self.sudo().search([('check_out', '=', False)])
@@ -329,8 +345,6 @@ class HrAttendance(models.Model):
continue continue
employee = att.employee_id employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
clock_out_time = effective_deadline clock_out_time = effective_deadline
try: try:
with self.env.cr.savepoint(): with self.env.cr.savepoint():
@@ -340,10 +354,6 @@ class HrAttendance(models.Model):
'x_fclk_grace_used': True, 'x_fclk_grace_used': True,
'x_fclk_clock_source': 'auto', 'x_fclk_clock_source': 'auto',
}) })
if (att.worked_hours or 0) >= threshold:
att.sudo().write(
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
)
att.sudo().message_post( att.sudo().message_post(
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} " body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h", f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",

View File

@@ -215,6 +215,23 @@ class HrEmployee(models.Model):
) )
) )
def _get_fclk_break_rule(self):
"""Return the statutory break rule for this employee.
Resolution: company's province -> matching rule; else the global default
rule; else an empty recordset (caller treats as zero break). Read via
sudo so the portal net-hours compute can resolve it without a direct ACL.
"""
self.ensure_one()
Rule = self.env['fusion.clock.break.rule'].sudo()
rule = Rule.browse()
state = self.company_id.state_id
if state:
rule = Rule.search([('state_id', '=', state.id)], limit=1)
if not rule:
rule = Rule.search([('is_default', '=', True)], limit=1)
return rule
def _get_fclk_scheduled_times(self, date): def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date. """Return (scheduled_in_dt, scheduled_out_dt) for a given date.

View File

@@ -36,12 +36,6 @@ class ResConfigSettings(models.TransientModel):
default=30.0, default=30.0,
help="Default unpaid break duration in minutes.", help="Default unpaid break duration in minutes.",
) )
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
default=4.0,
help="Only deduct break if shift is longer than this many hours.",
)
# ── Attendance Rules ─────────────────────────────────────────────── # ── Attendance Rules ───────────────────────────────────────────────
fclk_enable_auto_clockout = fields.Boolean( fclk_enable_auto_clockout = fields.Boolean(

View File

@@ -27,3 +27,4 @@ access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,ba
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0 access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0 access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 access_fusion_clock_nfc_enrollment_wizard_manager fusion.clock.nfc.enrollment.wizard.manager model_fusion_clock_nfc_enrollment_wizard group_fusion_clock_manager 1 1 1 1
30 access_fusion_clock_break_rule_manager fusion.clock.break.rule.manager model_fusion_clock_break_rule group_fusion_clock_manager 1 1 1 1

View File

@@ -9,3 +9,4 @@ from . import test_dashboard
from . import test_pay_period from . import test_pay_period
from . import test_settings from . import test_settings
from . import test_clock_kiosk from . import test_clock_kiosk
from . import test_break_rules

View File

@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo.tests import tagged, TransactionCase
from odoo.exceptions import ValidationError
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestBreakRules(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
cls.Rule = cls.env['fusion.clock.break.rule']
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
# Flexible calendar -> hr.attendance.worked_hours is the raw check_in..check_out
# span (no lunch interval subtracted), so the tier math below is deterministic.
# This also mirrors real clock-in/out employees, who are effectively flexible.
flex = cls.env['resource.calendar'].create({
'name': 'FCLK Flexible', 'flexible_hours': True,
})
cls.employee = cls.env['hr.employee'].create({
'name': 'FCLK Break Test', 'resource_calendar_id': flex.id,
})
def _mk_att(self, hours):
check_in = datetime(2026, 1, 5, 9, 0, 0)
return self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': check_in,
'check_out': check_in + timedelta(hours=hours),
})
# ---- Task 1: tier engine + constraints ----
def test_break_minutes_for_tiers(self):
rule = self.Rule.create({
'name': 'Tier Test', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
def test_second_tier_must_exceed_first(self):
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Bad', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
})
def test_single_default_enforced(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Another Default', 'is_default': True, 'active': True,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
# ---- Task 2: jurisdiction resolver ----
def test_resolver_matches_company_province(self):
bc = self.env.ref('base.state_ca_bc')
bc_rule = self.Rule.create({
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.employee.company_id.state_id = bc.id
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
def test_resolver_falls_back_to_default(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
self.employee.company_id.state_id = alberta.id
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
# ---- Task 3: automatic deduction on every path ----
def test_manual_attendance_applies_statutory_break(self):
att = self._mk_att(6) # 6h >= 5 -> first break
self.assertEqual(att.x_fclk_break_minutes, 30.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
def test_manual_edit_extends_break(self):
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 30.0)
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
self.assertEqual(att.x_fclk_break_minutes, 60.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
def test_under_first_threshold_no_break(self):
att = self._mk_att(4) # 4h < 5 -> nothing
self.assertEqual(att.x_fclk_break_minutes, 0.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
def test_penalty_minutes_are_additive(self):
att = self._mk_att(6) # statutory 30
self.env['fusion.clock.penalty'].create({
'attendance_id': att.id,
'employee_id': self.employee.id,
'penalty_type': 'early_out',
'penalty_minutes': 15.0,
'date': att.check_in.date(),
})
self.assertEqual(att.x_fclk_break_minutes, 45.0)
def test_master_toggle_off_zero_statutory(self):
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 0.0)
def test_open_attendance_zero_break(self):
att = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2026, 1, 5, 9, 0, 0),
})
self.assertEqual(att.x_fclk_break_minutes, 0.0)

View File

@@ -40,3 +40,4 @@ class TestFusionClockSettings(TransactionCase):
fields = self.env['res.config.settings']._fields fields = self.env['res.config.settings']._fields
self.assertNotIn('fclk_grace_period_minutes', fields) self.assertNotIn('fclk_grace_period_minutes', fields)
self.assertNotIn('fclk_weekly_overtime_threshold', fields) self.assertNotIn('fclk_weekly_overtime_threshold', fields)
self.assertNotIn('fclk_break_threshold_hours', fields)

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.list</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="state_id"/>
<field name="country_id" optional="hide"/>
<field name="break1_after_hours" widget="float_time"/>
<field name="break1_minutes"/>
<field name="break2_after_hours" widget="float_time"/>
<field name="break2_minutes"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.form</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
</div>
<group>
<group string="Jurisdiction">
<field name="country_id"/>
<field name="state_id"
domain="[('country_id', '=', country_id)]"/>
<field name="is_default"/>
<field name="active"/>
</group>
<group string="Unpaid Break Tiers">
<label for="break1_after_hours" string="First break after"/>
<div class="o_row">
<field name="break1_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break1_minutes"/>
<span>min</span>
</div>
<label for="break2_after_hours" string="Second break after"/>
<div class="o_row">
<field name="break2_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break2_minutes"/>
<span>min</span>
</div>
</group>
</group>
<p class="text-muted">
Breaks are unpaid and deducted from actual worked hours. A tier with
0 minutes is disabled. Triggers are inclusive — a break applies when
worked hours are equal to or above the threshold.
</p>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
<field name="name">Break Rules</field>
<field name="res_model">fusion.clock.break.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
the rule matching their company's province, or the default rule.</p>
</field>
</record>
</odoo>

View File

@@ -196,6 +196,13 @@
sequence="20" sequence="20"
groups="group_fusion_clock_manager"/> groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_break_rules"
name="Break Rules"
parent="menu_fusion_clock_config"
action="action_fusion_clock_break_rule"
sequence="25"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_nfc_enrollment" <menuitem id="menu_fusion_clock_nfc_enrollment"
name="Enroll NFC Card" name="Enroll NFC Card"
parent="menu_fusion_clock_config" parent="menu_fusion_clock_config"

View File

@@ -28,16 +28,16 @@
</div> </div>
</setting> </setting>
<setting id="fclk_auto_break" string="Auto-Deduct Break" <setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct unpaid break from worked hours on clock-out."> help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
<field name="fclk_auto_deduct_break"/> <field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break"> <div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/> <label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/> <field name="fclk_default_break_minutes"/>
</div> </div>
<div class="row mt8"> <div class="text-muted small mt4">
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/> Used as the default break when building shifts/schedules
<field name="fclk_break_threshold_hours" widget="float_time"/> (planned hours). Actual deductions follow the province Break Rules.
</div> </div>
</div> </div>
</setting> </setting>