diff --git a/fusion_clock/CLAUDE.md b/fusion_clock/CLAUDE.md index c92f57db..1521d93e 100644 --- a/fusion_clock/CLAUDE.md +++ b/fusion_clock/CLAUDE.md @@ -5,7 +5,7 @@ ## 1. What This Module Is - **Name**: Fusion Clock. -- **Version**: `19.0.3.3.0`. +- **Version**: `19.0.4.1.0`. - **Category**: Human Resources/Attendances. - **License**: OPL-1, Nexa Systems Inc. - **Purpose**: complete time and attendance app built on Odoo `hr.attendance`. @@ -68,6 +68,7 @@ Custom models: | `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. | | `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. | | `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. | +| `fusion.clock.break.rule` | `models/clock_break_rule.py` | Per-province statutory unpaid-break thresholds (2-tier: first break after N1 h, second after N2 h). | | `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. | Inherited models: @@ -101,7 +102,7 @@ Clock-out flow: 1. Verify location again. 2. Call `_attendance_action_change()`. 3. Write out-distance. -4. Apply break deduction when configured. +4. Break is deducted automatically — `x_fclk_break_minutes` is a stored compute (see §13), not an explicit controller step. 5. Create `early_out` penalty when outside grace. 6. Log `clock_out`. 7. Log overtime if computed overtime is positive. @@ -252,7 +253,6 @@ fusion_clock.default_clock_in_time fusion_clock.default_clock_out_time fusion_clock.default_break_minutes fusion_clock.auto_deduct_break -fusion_clock.break_threshold_hours fusion_clock.enable_auto_clockout fusion_clock.max_shift_hours fusion_clock.enable_penalties @@ -327,8 +327,8 @@ All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`. - Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets. - `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons. -- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field. -- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. +- **`hr.attendance.x_fclk_break_minutes` is a stored COMPUTE, not a writable field** (`_compute_fclk_break_minutes`): statutory break (per the employee's province `fusion.clock.break.rule`, from actual `worked_hours`, 2-tier — first break after N1 h, second after N2 h, inclusive `>=`) **plus** Σ penalty minutes. It recomputes on every path incl. manual backend create/edit, which is what makes the break auto-apply on manually-entered hours. NEVER `write()` it — change the province rule or toggle `fusion_clock.auto_deduct_break` instead. Penalty minutes are now strictly additive (the old controller `max()` that could swallow a late clock-in penalty is gone). Rule resolved via `hr.employee._get_fclk_break_rule()` (company `state_id` → matching rule → global `is_default` rule). The retired `break_threshold_hours` setting is superseded by per-rule `break1_after_hours`. +- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. **Gotcha: `worked_hours` itself subtracts the resource-calendar lunch interval for NON-flexible employees** (Odoo core `hr.attendance._get_worked_hours_in_range`), so the statutory tiers run on lunch-excluded hours; flexible / no-calendar employees get the raw check_in→check_out span. Tests that need a deterministic span give the employee a `flexible_hours` calendar. - Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.) - `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on). - **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`. diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index f2ed0b42..c9fb7e0f 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.4.0.3', + 'version': '19.0.4.1.0', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ @@ -52,6 +52,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'security/ir.model.access.csv', # Data 'data/ir_config_parameter_data.xml', + 'data/clock_break_rule_data.xml', 'data/ir_cron_data.xml', # Reports (must load before mail templates that reference them) 'report/clock_report_template.xml', @@ -71,6 +72,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'views/clock_dashboard_views.xml', 'views/hr_employee_views.xml', 'views/clock_schedule_views.xml', + 'views/clock_break_rule_views.xml', # Wizards (must load before clock_menus.xml since menu references wizard action) 'wizard/clock_nfc_enrollment_views.xml', 'wizard/clock_period_picker_views.xml', diff --git a/fusion_clock/controllers/clock_api.py b/fusion_clock/controllers/clock_api.py index 41bd020e..59464870 100644 --- a/fusion_clock/controllers/clock_api.py +++ b/fusion_clock/controllers/clock_api.py @@ -5,7 +5,6 @@ import base64 import math import logging -import pytz from datetime import datetime, timedelta from odoo import http, fields, _ from odoo.http import request @@ -137,12 +136,6 @@ class FusionClockAPI(http.Controller): 'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee), }) - # Deduct penalty minutes from attendance (adds to break deduction) - current_break = attendance.x_fclk_break_minutes or 0.0 - attendance.sudo().write({ - 'x_fclk_break_minutes': current_break + deduction, - }) - # Log penalty log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out' request.env['fusion.clock.activity.log'].sudo().create({ @@ -158,32 +151,6 @@ class FusionClockAPI(http.Controller): if penalty_type == 'late_in': employee.sudo().write({'x_fclk_ontime_streak': 0}) - def _apply_break_deduction(self, attendance, employee): - """Apply automatic break deduction if configured.""" - ICP = request.env['ir.config_parameter'].sudo() - if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True': - return - - threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) - worked = attendance.worked_hours or 0.0 - - if worked >= threshold: - local_date = get_local_today(request.env, employee) - if attendance.check_in: - tz_name = ( - employee.resource_id.tz - or (employee.user_id.partner_id.tz if employee.user_id else False) - or employee.company_id.partner_id.tz - or 'UTC' - ) - local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date() - break_min = employee._get_fclk_break_minutes(local_date) - current = attendance.x_fclk_break_minutes or 0.0 - # Set to whichever is higher: configured break or existing (penalty-inflated) value - new_val = max(break_min, current) - if new_val != current: - attendance.sudo().write({'x_fclk_break_minutes': new_val}) - def _log_activity(self, employee, log_type, description, attendance=None, location=None, latitude=0, longitude=0, distance=0, source='portal'): """Create an activity log entry.""" @@ -405,9 +372,6 @@ class FusionClockAPI(http.Controller): 'x_fclk_out_distance': round(distance, 1), }) - # Apply break deduction - self._apply_break_deduction(attendance, employee) - # Check for early clock-out penalty if not is_scheduled_off: _, scheduled_out = self._get_scheduled_times(employee, today) diff --git a/fusion_clock/controllers/clock_kiosk.py b/fusion_clock/controllers/clock_kiosk.py index 9b0441f0..3ed5db98 100644 --- a/fusion_clock/controllers/clock_kiosk.py +++ b/fusion_clock/controllers/clock_kiosk.py @@ -155,7 +155,6 @@ class FusionClockKiosk(http.Controller): 'x_fclk_out_distance': 0.0, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, }) - api._apply_break_deduction(attendance, employee) if not is_scheduled_off: _, scheduled_out = api._get_scheduled_times(employee, today) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index 96fac51b..c2c05dce 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -378,7 +378,6 @@ class FusionClockNfcKiosk(http.Controller): 'x_fclk_out_distance': 0.0, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, }) - api._apply_break_deduction(attendance, employee) if not is_scheduled_off: _, scheduled_out = api._get_scheduled_times(employee, today) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) diff --git a/fusion_clock/data/clock_break_rule_data.xml b/fusion_clock/data/clock_break_rule_data.xml new file mode 100644 index 00000000..fcbc3a3c --- /dev/null +++ b/fusion_clock/data/clock_break_rule_data.xml @@ -0,0 +1,13 @@ + + + + Ontario + + + + 5.0 + 30.0 + 10.0 + 30.0 + + diff --git a/fusion_clock/data/ir_config_parameter_data.xml b/fusion_clock/data/ir_config_parameter_data.xml index f024abec..d946ac5d 100644 --- a/fusion_clock/data/ir_config_parameter_data.xml +++ b/fusion_clock/data/ir_config_parameter_data.xml @@ -20,10 +20,6 @@ fusion_clock.auto_deduct_break True - - fusion_clock.break_threshold_hours - 4.0 - diff --git a/fusion_clock/migrations/19.0.4.1.0/post-migrate.py b/fusion_clock/migrations/19.0.4.1.0/post-migrate.py new file mode 100644 index 00000000..0a853196 --- /dev/null +++ b/fusion_clock/migrations/19.0.4.1.0/post-migrate.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, SUPERUSER_ID + + +def migrate(cr, version): + """Retire the single-threshold break param (superseded by per-rule + break1_after_hours), and force-recompute the now-computed break field so + existing closed attendances reflect the province rule + their penalties.""" + cr.execute( + "DELETE FROM ir_config_parameter WHERE key = %s", + ('fusion_clock.break_threshold_hours',), + ) + env = api.Environment(cr, SUPERUSER_ID, {}) + Attendance = env['hr.attendance'] + field = Attendance._fields['x_fclk_break_minutes'] + closed = Attendance.search([('check_out', '!=', False)]) + if closed: + env.add_to_compute(field, closed) + closed.flush_recordset(['x_fclk_break_minutes']) diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index fa59a31a..c51939ad 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -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 diff --git a/fusion_clock/models/clock_break_rule.py b/fusion_clock/models/clock_break_rule.py new file mode 100644 index 00000000..c812053c --- /dev/null +++ b/fusion_clock/models/clock_break_rule.py @@ -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)) diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index 95a8c3fa..5de4ad64 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -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", diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index f8a73e0e..d459f2bc 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -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. diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index 9da8a313..d00c53e3 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -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( diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv index f36b307c..644cecff 100644 --- a/fusion_clock/security/ir.model.access.csv +++ b/fusion_clock/security/ir.model.access.csv @@ -27,3 +27,4 @@ access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,ba access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0 access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0 access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1 diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index 4bc1c411..37af63d0 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -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 diff --git a/fusion_clock/tests/test_break_rules.py b/fusion_clock/tests/test_break_rules.py new file mode 100644 index 00000000..0ea323bd --- /dev/null +++ b/fusion_clock/tests/test_break_rules.py @@ -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) diff --git a/fusion_clock/tests/test_settings.py b/fusion_clock/tests/test_settings.py index 0ed877f9..68d11a45 100644 --- a/fusion_clock/tests/test_settings.py +++ b/fusion_clock/tests/test_settings.py @@ -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) diff --git a/fusion_clock/views/clock_break_rule_views.xml b/fusion_clock/views/clock_break_rule_views.xml new file mode 100644 index 00000000..e53148dc --- /dev/null +++ b/fusion_clock/views/clock_break_rule_views.xml @@ -0,0 +1,79 @@ + + + + fusion.clock.break.rule.list + fusion.clock.break.rule + + + + + + + + + + + + + + + + + + fusion.clock.break.rule.form + fusion.clock.break.rule + +
+ + +
+

+
+ + + + + + + + + + +

+ Breaks are unpaid and deducted from actual worked hours. A tier with + 0 minutes is disabled. Triggers are inclusive — a break applies when + worked hours are equal to or above the threshold. +

+
+
+
+
+ + + Break Rules + fusion.clock.break.rule + list,form + {'active_test': False} + +

Create a statutory break rule

+

Define unpaid meal-break thresholds per province/country. Employees inherit + the rule matching their company's province, or the default rule.

+
+
+
diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index bc4485fe..672ed6b7 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -196,6 +196,13 @@ sequence="20" groups="group_fusion_clock_manager"/> + + + help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
-
-
-