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:
@@ -5,6 +5,7 @@ from . import clock_location
|
||||
from . import hr_attendance
|
||||
from . import hr_employee
|
||||
from . import clock_penalty
|
||||
from . import clock_break_rule
|
||||
from . import clock_report
|
||||
from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
|
||||
85
fusion_clock/models/clock_break_rule.py
Normal file
85
fusion_clock/models/clock_break_rule.py
Normal 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))
|
||||
@@ -161,9 +161,12 @@ class HrAttendance(models.Model):
|
||||
)
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
compute='_compute_fclk_break_minutes',
|
||||
store=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(
|
||||
string='Net Hours',
|
||||
@@ -258,6 +261,20 @@ class HrAttendance(models.Model):
|
||||
def _search_fclk_in_next_period(self, 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')
|
||||
def _compute_net_hours(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'))
|
||||
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()
|
||||
open_attendances = self.sudo().search([('check_out', '=', False)])
|
||||
@@ -329,8 +345,6 @@ class HrAttendance(models.Model):
|
||||
continue
|
||||
|
||||
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
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
@@ -340,10 +354,6 @@ class HrAttendance(models.Model):
|
||||
'x_fclk_grace_used': True,
|
||||
'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(
|
||||
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",
|
||||
|
||||
@@ -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):
|
||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||
|
||||
|
||||
@@ -36,12 +36,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
default=30.0,
|
||||
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 ───────────────────────────────────────────────
|
||||
fclk_enable_auto_clockout = fields.Boolean(
|
||||
|
||||
Reference in New Issue
Block a user