41 KiB
Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, and manual backend entry), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
Architecture: A new fusion.clock.break.rule table holds the per-province thresholds. hr.employee._get_fclk_break_rule() resolves an employee's rule from its company's province (global default fallback). hr.attendance.x_fclk_break_minutes becomes a single stored computed field — statutory_break(worked_hours) + Σ penalty_minutes — that recomputes on every save and replaces the four scattered write sites (controller _apply_break_deduction ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
Tech Stack: Odoo 19, Python, QWeb/XML views, Odoo test framework (TransactionCase).
Spec: docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md
Dev environment & sync (READ FIRST — applies to every task)
Two working copies (per project memory feedback_dual_path_fusion_clock):
- Git/source tree (edit + commit here):
K:\Github\Odoo-Modules\fusion_clock - Docker/active tree (what the container loads):
K:\Github\odoo-modsdev\addons\fusion_clock
Edit in the git tree, then mirror to the Docker tree before every test run:
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
(robocopy exit codes < 8 = success.) Preflight: if K:\Github\odoo-modsdev\addons\fusion_clock does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
Container/DB: odoo-modsdev-app / db modsdev (per memory reference_docker_env_names).
Canonical commands (note the ephemeral ports — --test-enable forces http_spawn() so 8069/8072 collide without them; per repo CLAUDE.md):
- Run this module's tests:
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100 - Plain upgrade (no tests):
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50 - Pyflakes a changed Python file (catches undefined names instantly):
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
Commit: only from the git tree (git -C "K:/Github/Odoo-Modules" ...). Per memory feedback_always_push_to_main, push after each commit on main.
File Structure
Created:
fusion_clock/models/clock_break_rule.py— thefusion.clock.break.rulemodel + tier engine + constraints.fusion_clock/data/clock_break_rule_data.xml— seed Ontario rule (is_default).fusion_clock/views/clock_break_rule_views.xml— list/form/action for the rule.fusion_clock/migrations/19.0.4.1.0/post-migrate.py— drop retired param + recompute break.fusion_clock/tests/test_break_rules.py— all new tests.
Modified:
fusion_clock/models/__init__.py— import the new model.fusion_clock/models/hr_employee.py— add_get_fclk_break_rule().fusion_clock/models/hr_attendance.py—x_fclk_break_minutes→ stored compute; drop cron break-write.fusion_clock/controllers/clock_api.py— delete_apply_break_deduction, its clock-out call, and the penalty break-write.fusion_clock/controllers/clock_kiosk.py— delete the_apply_break_deductioncall.fusion_clock/controllers/clock_nfc_kiosk.py— delete the_apply_break_deductioncall.fusion_clock/models/res_config_settings.py— removefclk_break_threshold_hours.fusion_clock/views/res_config_settings_views.xml— remove threshold row; relabel default-break as scheduling-only; point to Break Rules.fusion_clock/data/ir_config_parameter_data.xml— remove thebreak_threshold_hoursseed record.fusion_clock/security/ir.model.access.csv— manager access for the new model.fusion_clock/views/clock_menus.xml— "Break Rules" config menu.fusion_clock/__manifest__.py— version bump + new data/view files.fusion_clock/tests/__init__.py— import the new test module.fusion_clock/tests/test_settings.py— assert the retired field is gone.fusion_clock/CLAUDE.md— model map, settings keys, break gotcha (Task 5).
Behaviour-change note (intentional, approved by spec §4.3): today a late-in penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because _apply_break_deduction does max(break, current). The new compute makes all penalty minutes strictly additive (statutory + Σ penalties), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
Task 1: New model fusion.clock.break.rule
Files:
-
Create:
fusion_clock/models/clock_break_rule.py -
Create:
fusion_clock/data/clock_break_rule_data.xml -
Create:
fusion_clock/views/clock_break_rule_views.xml -
Create:
fusion_clock/tests/test_break_rules.py -
Modify:
fusion_clock/models/__init__.py -
Modify:
fusion_clock/tests/__init__.py -
Modify:
fusion_clock/security/ir.model.access.csv -
Modify:
fusion_clock/views/clock_menus.xml -
Modify:
fusion_clock/__manifest__.py -
Step 1: Write the failing tests — create
fusion_clock/tests/test_break_rules.py:
# -*- 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)
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
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,
})
Append the import to fusion_clock/tests/__init__.py (add the line if not already present):
from . import test_break_rules
- Step 2: Create the model —
fusion_clock/models/clock_break_rule.py:
# -*- 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))
- Step 3: Register the model — add to
fusion_clock/models/__init__.pyafter theclock_penaltyimport:
from . import clock_break_rule
- Step 4: Grant access — append one row to
fusion_clock/security/ir.model.access.csv:
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
(No user/portal grant needed — the resolver reads the table via sudo().)
- Step 5: Seed the Ontario rule — create
fusion_clock/data/clock_break_rule_data.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="break_rule_ontario" model="fusion.clock.break.rule">
<field name="name">Ontario</field>
<field name="country_id" ref="base.ca"/>
<field name="state_id" ref="base.state_ca_on"/>
<field name="is_default" eval="True"/>
<field name="break1_after_hours">5.0</field>
<field name="break1_minutes">30.0</field>
<field name="break2_after_hours">10.0</field>
<field name="break2_minutes">30.0</field>
</record>
</odoo>
- Step 6: Views + action — create
fusion_clock/views/clock_break_rule_views.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.list</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="state_id"/>
<field name="country_id" optional="hide"/>
<field name="break1_after_hours" widget="float_time"/>
<field name="break1_minutes"/>
<field name="break2_after_hours" widget="float_time"/>
<field name="break2_minutes"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.form</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
</div>
<group>
<group string="Jurisdiction">
<field name="country_id"/>
<field name="state_id"
domain="[('country_id', '=', country_id)]"/>
<field name="is_default"/>
<field name="active"/>
</group>
<group string="Unpaid Break Tiers">
<label for="break1_after_hours" string="First break after"/>
<div class="o_row">
<field name="break1_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break1_minutes"/>
<span>min</span>
</div>
<label for="break2_after_hours" string="Second break after"/>
<div class="o_row">
<field name="break2_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break2_minutes"/>
<span>min</span>
</div>
</group>
</group>
<p class="text-muted">
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.
</p>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
<field name="name">Break Rules</field>
<field name="res_model">fusion.clock.break.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
the rule matching their company's province, or the default rule.</p>
</field>
</record>
</odoo>
- Step 7: Add the menu — in
fusion_clock/views/clock_menus.xml, insert after themenu_fusion_clock_locations_configmenuitem (the Locations config item) and beforemenu_fusion_clock_nfc_enrollment:
<menuitem id="menu_fusion_clock_break_rules"
name="Break Rules"
parent="menu_fusion_clock_config"
action="action_fusion_clock_break_rule"
sequence="25"
groups="group_fusion_clock_manager"/>
-
Step 8: Wire the manifest — in
fusion_clock/__manifest__.py:Do NOT bump the version yet — it stays
19.0.4.0.3until Task 4, so the19.0.4.1.0migration actually fires in dev (Odoo only runs a version's migration when the installed version is lower than the manifest version).Add the seed data file after
'data/ir_config_parameter_data.xml',:
'data/clock_break_rule_data.xml',
Add the view file after 'views/clock_schedule_views.xml',:
'views/clock_break_rule_views.xml',
(Data and view files reload on every -u regardless of the version number, so the
new model/menu install without a bump. No assets change in this plan, so the bump's
only purpose is the migration trigger — deferred to Task 4.)
-
Step 9: Sync, upgrade, run tests
Sync (see preamble), then:
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
Expected: module upgrades cleanly; test_break_minutes_for_tiers, test_second_tier_must_exceed_first, test_single_default_enforced PASS. (Other tests in the class will error until Tasks 2–3 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
- Step 10: Commit
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
Task 2: Jurisdiction resolver on hr.employee
Files:
-
Modify:
fusion_clock/models/hr_employee.py -
Modify:
fusion_clock/tests/test_break_rules.py -
Step 1: Add the resolver tests — append these methods to
TestBreakRulesinfusion_clock/tests/test_break_rules.py:
# ---- 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)
-
Step 2: Run to verify they fail
Sync, then:
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
Expected: FAIL — AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'.
- Step 3: Implement the resolver — in
fusion_clock/models/hr_employee.py, add this method immediately after the_get_fclk_break_minutesmethod (after itsreturn float(...)block, before_get_fclk_scheduled_times):
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
-
Step 4: Run to verify they pass
Sync, then re-run the Step 2 command. Expected:
test_resolver_matches_company_provinceandtest_resolver_falls_back_to_defaultPASS. -
Step 5: Commit
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
Task 3: x_fclk_break_minutes → stored compute; remove all manual writes
This task is atomic: once the field is computed (no inverse), any remaining write({'x_fclk_break_minutes': ...}) raises at runtime, so the field conversion and the removal of all four write sites must land together.
Files:
-
Modify:
fusion_clock/models/hr_attendance.py -
Modify:
fusion_clock/controllers/clock_api.py -
Modify:
fusion_clock/controllers/clock_kiosk.py -
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py -
Modify:
fusion_clock/tests/test_break_rules.py -
Step 1: Add the attendance tests — append these methods to
TestBreakRulesinfusion_clock/tests/test_break_rules.py:
# ---- 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)
-
Step 2: Run to verify they fail
Sync, then run the module tests. Expected: the new tests FAIL — e.g.
test_manual_attendance_applies_statutory_breakasserts 30 but gets 0 (no write override exists yet). -
Step 3: Convert the field to a stored compute — in
fusion_clock/models/hr_attendance.py, replace the field definition:OLD:
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,
tracking=True,
help="Break duration in minutes to deduct from worked hours.",
)
NEW:
x_fclk_break_minutes = fields.Float(
string='Break (min)',
compute='_compute_fclk_break_minutes',
store=True,
tracking=True,
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.",
)
- Step 4: Add the compute method — in the same file, insert this method immediately before the
_compute_net_hoursmethod (just above its@api.depends('worked_hours', 'x_fclk_break_minutes')decorator):
@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
-
Step 5: Remove the cron's break write — in the same file, inside
_cron_fusion_auto_clock_out:Remove the now-unused threshold read (the line near the top of the method):
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
Remove the two now-unused locals in the per-attendance loop:
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()
Remove the break-write block (the compute now applies the break when check_out is set):
if (att.worked_hours or 0) >= threshold:
att.sudo().write(
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
)
(Leave the surrounding employee = att.employee_id and clock_out_time = effective_deadline lines intact.)
-
Step 6: Delete the controller helper and its call sites — in
fusion_clock/controllers/clock_api.py:Delete the entire
_apply_break_deductionmethod:
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})
Delete its clock-out call (in the CLOCK OUT branch):
# Apply break deduction
self._apply_break_deduction(attendance, employee)
Delete the penalty break-write in _check_and_create_penalty (keep the penalty-record create above it and the activity log below it):
# 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,
})
-
Step 7: Delete the kiosk call sites
In
fusion_clock/controllers/clock_kiosk.py, delete the line:
api._apply_break_deduction(attendance, employee)
In fusion_clock/controllers/clock_nfc_kiosk.py, delete the line:
api._apply_break_deduction(attendance, employee)
- Step 8: Pyflakes the touched controllers/models (catches a missed
pytz/var reference instantly)
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
Expected: no output (clean). If it flags pytz as unused in hr_attendance.py, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use pytz, so leave the import).
-
Step 9: Run to verify all Task 3 tests pass
Sync, then run the module tests. Expected: all
test_manual_*,test_under_first_threshold_no_break,test_penalty_minutes_are_additive,test_master_toggle_off_zero_statutory,test_open_attendance_zero_breakPASS, and the existing NFC/kiosk/dashboard tests still PASS. -
Step 10: Commit
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
Task 4: Retire break_threshold_hours; clean settings & migrate
Files:
-
Modify:
fusion_clock/models/res_config_settings.py -
Modify:
fusion_clock/views/res_config_settings_views.xml -
Modify:
fusion_clock/data/ir_config_parameter_data.xml -
Create:
fusion_clock/migrations/19.0.4.1.0/post-migrate.py -
Modify:
fusion_clock/tests/test_settings.py -
Step 1: Add the dead-setting assertion — in
fusion_clock/tests/test_settings.py, add one line totest_dead_settings_removed:
self.assertNotIn('fclk_break_threshold_hours', fields)
- Step 2: Remove the settings field — in
fusion_clock/models/res_config_settings.py, delete:
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.",
)
-
Step 3: Fix the settings view — in
fusion_clock/views/res_config_settings_views.xml, replace the wholefclk_auto_breaksetting block:OLD:
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct unpaid break from worked hours on clock-out.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="row mt8">
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_break_threshold_hours" widget="float_time"/>
</div>
</div>
</setting>
NEW:
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="text-muted small mt4">
Used as the default break when building shifts/schedules
(planned hours). Actual deductions follow the province Break Rules.
</div>
</div>
</setting>
- Step 4: Remove the seed param — in
fusion_clock/data/ir_config_parameter_data.xml, delete:
<record id="config_break_threshold_hours" model="ir.config_parameter">
<field name="key">fusion_clock.break_threshold_hours</field>
<field name="value">4.0</field>
</record>
-
Step 5: Bump the version + create the migration
First bump the manifest so the migration fires (installed
19.0.4.0.3< manifest19.0.4.1.0). Infusion_clock/__manifest__.py:
'version': '19.0.4.1.0',
Then create fusion_clock/migrations/19.0.4.1.0/post-migrate.py:
# -*- 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'])
-
Step 6: Sync, upgrade, run tests
Sync, then run the module tests. Expected: module upgrades cleanly and the
19.0.4.1.0migration executes (installed19.0.4.0.3< manifest19.0.4.1.0; modsdev shows the INFO line, nexa/entech runlog_level=warn),test_dead_settings_removedPASS, fullfusion_clocksuite green. -
Step 7: Verify the param is gone and historical rows recomputed (sanity)
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
ICP = env['ir.config_parameter'].sudo()
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
PY
Expected: threshold param: ABSENT; default rule: ['Ontario'].
- Step 8: Commit
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
Task 5: Full verification, docs, manual smoke
Files:
-
Modify:
fusion_clock/CLAUDE.md -
Step 1: Full test run (whole module)
Sync, then:
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -120
Expected: all fusion_clock tests PASS, zero tracebacks. If anything fails, fix before continuing.
-
Step 2: Manual smoke (manager UI) at http://localhost:8082
- Configuration → Break Rules exists; the Ontario row shows 5h→30 / 10h→30, Default ticked.
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → Break = 30, Net = 5.5h, with no clock action.
- Edit that record's check-out to 19:00 (10h) → Break = 60, Net = 9.0h.
- Create a 4h attendance → Break = 0.
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
-
Step 3: Update the module CLAUDE.md — in
fusion_clock/CLAUDE.md:- §4 Model Map: add a row —
fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier). - §5 Clocking Flow: note that the break deduction is no longer a controller step —
x_fclk_break_minutesis a stored compute (statutory(worked_hours) + Σ penalties) that fires on every path including manual backend entry; resolved rule viahr.employee._get_fclk_break_rule()(company province → default). - §11 Settings Keys: remove
fusion_clock.break_threshold_hours. - §13 Gotchas: add — "Unpaid break is computed, not written: never
write({'x_fclk_break_minutes': ...}); change the province rule (fusion.clock.break.rule) orauto_deduct_breakinstead. Penalty minutes are now strictly additive (the oldmax()that swallowed late-in penalties is gone)." - Bump the version line in §1 to
19.0.4.1.0.
- §4 Model Map: add a row —
-
Step 4: Commit the docs
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
- Step 5: Report — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (
odoo-entech) is a separate step pending user sign-off.
Self-Review (performed against the spec)
1. Spec coverage
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 1–3. §8 rollout → preamble + Task 5. ✓ No gaps.
2. Placeholder scan — every step has full code/commands; no TBD/TODO/"similar to". ✓
3. Type/name consistency — break_minutes_for, _get_fclk_break_rule, _compute_fclk_break_minutes, fields break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default, model fusion.clock.break.rule, access id model_fusion_clock_break_rule, action action_fusion_clock_break_rule, menu menu_fusion_clock_break_rules — all used identically across tasks. The compute folds Σ penalty_minutes (field penalty_minutes on fusion.clock.penalty, confirmed). ✓