Files
Odoo-Modules/fusion_clock/tests/test_break_rules.py
gsinghpal f7ec1e28f9 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>
2026-06-01 00:15:42 -04:00

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)