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>
125 lines
5.4 KiB
Python
125 lines
5.4 KiB
Python
# -*- 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)
|