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>
86 lines
3.4 KiB
Python
86 lines
3.4 KiB
Python
# -*- 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))
|