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:
@@ -9,3 +9,4 @@ from . import test_dashboard
|
||||
from . import test_pay_period
|
||||
from . import test_settings
|
||||
from . import test_clock_kiosk
|
||||
from . import test_break_rules
|
||||
|
||||
124
fusion_clock/tests/test_break_rules.py
Normal file
124
fusion_clock/tests/test_break_rules.py
Normal 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)
|
||||
@@ -40,3 +40,4 @@ class TestFusionClockSettings(TransactionCase):
|
||||
fields = self.env['res.config.settings']._fields
|
||||
self.assertNotIn('fclk_grace_period_minutes', fields)
|
||||
self.assertNotIn('fclk_weekly_overtime_threshold', fields)
|
||||
self.assertNotIn('fclk_break_threshold_hours', fields)
|
||||
|
||||
Reference in New Issue
Block a user